Skip to Content
ObservabilityConsistent Error Handling: Global Exception Handler

Consistent Error Handling: Global Exception Handler

What? (Concept Overview)

FastAPI’s default error response is {"detail": "Internal Server Error"} with no context, stack trace, or correlation id. The @app.exception_handler(Exception) decorator registers a fallback handler that catches every unhandled exception, logs it with exc_info=True, and returns a typed JSONResponse so clients always see the same envelope shape.

Project Context

The FCA Support Agent wraps the entire app with a global_exception_handler registered inside create_application(). The handler encodes the dev-vs-prod detail policy in ONE place: in DEBUG mode the raw exception message leaks; in production the message is replaced with a generic "An unexpected error occurred" to avoid leaking internals to attackers.

How? (Quick Reference Blocks)

3.1 The Global Exception Handler

# app/main.py — create_application from fastapi.responses import JSONResponse @app.exception_handler(Exception) async def global_exception_handler(request, exc): logger.error(f"Unhandled exception: {exc}", exc_info=True) return JSONResponse( status_code=500, content={ "error": "Internal Server Error", "message": str(exc) if settings.debug else "An unexpected error occurred", }, )

3.2 Domain-Specific Handlers (Preferred Over Catch-All)

# app/main.py — alongside the catch-all from fastapi import HTTPException @app.exception_handler(HTTPException) async def http_exception_handler(request, exc: HTTPException): # Preserve the original status code (4xx), only massage the body. return JSONResponse( status_code=exc.status_code, content={"error": exc.detail, "message": str(exc.detail)}, ) @app.exception_handler(ValueError) async def value_error_handler(request, exc: ValueError): logger.warning(f"Bad request: {exc}", extra={"path": request.url.path}) return JSONResponse(status_code=400, content={ "error": "Bad Request", "message": str(exc), })

Why? (Parameter Breakdown

  • @app.exception_handler(Exception) — Catches anything not handled by a more specific decorator. FastAPI’s lookup is reverse-registration order, so domain-specific handlers registered AFTER the catch-all DO win. Best practice: register domain-specific handlers LAST so they override the catch-all for known exception types.
  • exc_info=True — Tells the logger to capture the full traceback into the record. Without it, you only see ZeroDivisionError: division by zero with no stack.
  • str(exc) if settings.debug else "..." — Hides exception messages in production. Exception messages frequently include file paths, internal IPs, column names, or PII that should never reach an unauthenticated client. The settings.debug toggle is canonical.
  • JSONResponse over HTTPException re-raise — Re-raising would cause FastAPI’s default handler to wrap the message in {"detail": ...} (different envelope shape). Returning JSONResponse directly preserves the schema across the whole app.
  • Lazy logger.error with f"..." string — String interpolation only happens if the handler is invoked, NOT at registration. Avoids building strings for exceptions that never occur.

Common Pitfalls

  1. Catching Exception too broadly. This swallows KeyboardInterrupt and SystemExit-adjacent issues. FastAPI internally narrows this — but in tests using TestClient, an uncaught Exception in your handler CAN mask real bugs. Keep KeyboardInterrupt-class handling to the OS, not your handler.
  2. Returning exc.message or exc.args directly. These can leak PII ("user 42 not found" reveals internal IDs). Whitelist allowed fields explicitly.

Real-World Interview Prep

Q1: How would you add a trace_id to every error response so support can correlate user-reported failures?

A: Capture the trace ID at handler entry (from OpenTelemetry span context or a request middleware), inject it into the JSONResponse.content, and log it alongside the exception. Pattern:

trace_id = request.headers.get("x-trace-id") or uuid4().hex return JSONResponse( status_code=500, content={"error": "...", "trace_id": trace_id, "message": "..."}, headers={"x-trace-id": trace_id}, )

The user reports the trace_id; SRE searches the log store for trace_id=<value>. Without it every support ticket is a fishing expedition.

Q2: Why keep the DEBUG toggle in the handler instead of always returning sanitized messages?

A: Local development needs the raw exception message (AssertionError: expected 5, got 3 in test_foo) to debug. Production needs sanitised messages to avoid leaking internals to attackers. The settings.debug toggle is the canonical place to make this trade-off — it’s set by env var (DEBUG=true locally, absent in prod) and is part of the existing validated BaseSettings (see Pydantic-Settings Validation page).

Q3: How would you test a FastAPI handler hit by an unhandled exception?

A: Two patterns. Direct unit test against the handler function:

async def test_global_handler(): request = MagicMock() exc = ZeroDivisionError("boom") response = await global_exception_handler(request, exc) assert response.status_code == 500 assert response.body == b'{"error":"Internal Server Error","message":"boom"}'

Integration test via TestClient:

with pytest.raises(ZeroDivisionError): # if default behaviour client.get("/foo") # OR with handler set: response = client.get("/foo") assert response.status_code == 500 assert response.json()["error"] == "Internal Server Error"

Top-to-Bottom Code Walkthrough (app/main.py@app.exception_handler(Exception))

The global exception handler is the safety net. Every uncaught exception flows through it before reaching the client. Its job: log the truth, return a clean shape.

The handler

@app.exception_handler(Exception) async def global_exception_handler(request, exc): logger.error(f"Unhandled exception: {exc}", exc_info=True) return JSONResponse( status_code=500, content={ "error": "Internal Server Error", "message": str(exc) if settings.debug else "An unexpected error occurred", }, )

Why exc_info=True

Without it, Python’s logging context excludes the traceback — the file, line number, and function chain. With it, the log record carries exc_info which the JSON formatter serialises into a exception field. Errors without tracebacks are useless.

Why the message is conditional on settings.debug

In production: "An unexpected error occurred". The raw exception text might leak internal SQL, filesystem paths, or PII. Debug mode reveals the truth for local debugging.

Pydantic ValidationError handler

from fastapi.exceptions import RequestValidationError @app.exception_handler(RequestValidationError) async def validation_exception_handler(request, exc): logger.warning(f"Validation error: {exc.errors()}") return JSONResponse( status_code=422, content={"error": "Validation failed", "details": exc.errors()}, )

Validation errors deserve a different shape — they’re user-fixable. The handler returns 422 with the structured Pydantic error list, which the front-end can render inline.

JWT 401 handler

from jose import JWTError @app.exception_handler(JWTError) async def jwt_exception_handler(request, exc): return JSONResponse(status_code=401, content={"error": "Invalid or expired token"})

Bad tokens aren’t server bugs — they’re a normal sign of expired sessions. Logged at warning level, not error.

Order of processing

FastAPI’s exception handler chain:

  1. FastAPI built-ins (RequestValidationError, HTTPException) — caught first if a specific handler exists.
  2. App-specific handlers (your custom @app.exception_handler).
  3. Default 500 if no handler matches.

Register handlers with @app.exception_handler(ExceptionClass) BEFORE route registration (though order doesn’t strictly matter in FastAPI).

Why a single global handler (not per-route)

  • Consistent shape: every error response has the same JSON envelope.
  • One place to log: audit trail is centralised.
  • Don’t leak info in prod: see if settings.debug.

Common Pitfalls

Re-raising inside the handler — FastAPI’s exception system expects handlers to return a JSONResponse, not raise. Always return.

Logging str(exc) instead of using exc_info=True — message-only logs lack the traceback and are useless for debugging.

Catching Exception in route handlers before the global handler — if your route handler returns JSONResponse(status_code=500) instead of raising, the global handler never runs. Use raise consistently.

Real-World Interview Prep

Q1: Why not let FastAPI’s default 500 handler run?

A: Default returns Internal Server Error with no traceback on the server. The default shape may not match your client’s expectations (a SPA reading data.message won’t work). Own the error shape.

Q2: How do you test that the global handler works?

A: Add a route like @app.get("/test/error") that raises RuntimeError("test"). Call it via httpx and assert the response shape. Run as an integration test.

Q3: Should you log every 500?

A: Yes, but rate-limit the logs. If your DB goes down and 1000 requests/sec hit the handler, you’re logging 60,000 lines/sec. Use a sampling strategy: log the first 10 errors fully, then aggregate counts in a counter.

Last updated on