Backend / Web frameworks / FastAPI / 13_exception_handling.md

Exception handling

Updated 5 interview angles 4 min read source
On this page7
  1. What FastAPI already does
  2. One error shape, applied globally
  3. Subclass, do not catch-and-map
  4. Overriding the validation error
  5. The last-resort handler
  6. Related
  7. Interview angle

Exception handling

The default behaviour is good enough that people never look at it, and then every error in production is a bare 500 with a stack trace in the logs and nothing useful in the response.

What FastAPI already does

Raised Becomes
HTTPException that status, {"detail": ...}
RequestValidationError 422 with the field errors
ResponseValidationError 500 — your response broke the contract
anything else 500, logged, body says nothing

The third row is the one worth knowing: if your handler returns something that does not match response_model, FastAPI raises rather than sending it. That is a feature — a contract you publish and then violate is worse than an error — and it surprises people who expected the extra field to be dropped silently.

One error shape, applied globally

Return the same body for every failure, so clients parse one thing:

python
from fastapi.responses import JSONResponse

class DomainError(Exception):
    status = 400
    code = "domain_error"

    def __init__(self, detail: str):
        self.detail = detail

@app.exception_handler(DomainError)
async def domain_error(request: Request, exc: DomainError) -> JSONResponse:
    return JSONResponse(
        status_code=exc.status,
        content={
            "type": f"https://errors.example.com/{exc.code}",
            "title": exc.detail,
            "status": exc.status,
            "trace_id": request_id.get(),
        },
        media_type="application/problem+json",
    )

application/problem+json is RFC 9457 (which obsoleted 7807), and using it means clients get a machine-readable shape instead of a bespoke one per service. See Status codes.

Include the trace id in the body. A user pasting an error into a ticket then hands you the exact request, which is the difference between a five-minute investigation and an afternoon.

Subclass, do not catch-and-map

The pattern that scales is one base exception per domain concept, raised deep and handled once:

python
class NotFound(DomainError):
    status = 404
    code = "not_found"

class Conflict(DomainError):
    status = 409
    code = "conflict"
python
# Deep in the service layer. No HTTP vocabulary here.
if not order:
    raise NotFound(f"order {order_id}")

The service layer raises a domain error; the handler translates it to HTTP once. Compare with raise HTTPException(404) inside a service function, which puts HTTP knowledge in code that should not have any and makes the same function unusable from a Celery worker or a CLI.

Overriding the validation error

The default 422 body is a list of Pydantic errors, which is useful and does not match your error shape. Override it so clients see one format:

python
@app.exception_handler(RequestValidationError)
async def validation_error(request: Request, exc: RequestValidationError):
    return JSONResponse(
        status_code=422,
        content={
            "type": "https://errors.example.com/validation",
            "title": "Validation failed",
            "status": 422,
            "errors": [
                {"field": ".".join(str(p) for p in e["loc"][1:]), "detail": e["msg"]}
                for e in exc.errors()
            ],
        },
    )

e["loc"][1:] drops the leading "body" or "query", which is noise to a client that already knows where it put the field.

Gotcha: an exception handler registered for Exception does not catch everything. HTTPException and RequestValidationError have their own handlers registered first and win. And a handler that itself raises produces a bare 500 with no body at all, so keep them boring — no database calls, no outbound requests.

The last-resort handler

python
@app.exception_handler(Exception)
async def unhandled(request: Request, exc: Exception) -> JSONResponse:
    log.exception("unhandled", extra={"trace_id": request_id.get()})
    return JSONResponse(
        status_code=500,
        content={"title": "Internal error", "status": 500,
                 "trace_id": request_id.get()},
    )

Note what it does not do: never put str(exc) in the body. Exception text routinely contains a connection string, a file path or a row of data, and this is the single most common way an internal detail reaches a user.

Interview angle 5

  • “What happens to an unhandled exception in FastAPI?” - a 500 with no useful body, logged server-side. Register a handler for Exception to return a consistent shape with a trace id — and never put str(exc) in it, because exception text carries connection strings and data.
  • “How do you keep one error format across a service?” - a domain exception base class with a status and a code, raised deep in the service layer, translated to HTTP once in a registered handler. application/problem+json (RFC 9457) gives clients a machine-readable shape.
  • “Why not raise HTTPException in the service layer?” - it puts HTTP vocabulary in code that should not have any, and makes the same function unusable from a Celery worker or a CLI. Raise a domain error; let the handler map it.
  • “Does a handler for Exception catch everything?” - no. HTTPException and RequestValidationError have their own handlers that are matched first. And a handler that raises produces a bare 500 with no body, so keep handlers free of I/O.
  • “What is ResponseValidationError?” - your handler returned something that does not satisfy response_model, so FastAPI raised instead of sending it. It is a 500 because the bug is server-side: you published a contract and broke it.