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:
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:
class NotFound(DomainError):
status = 404
code = "not_found"
class Conflict(DomainError):
status = 409
code = "conflict"# 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:
@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
Exceptiondoes not catch everything.HTTPExceptionandRequestValidationErrorhave 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
@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.
Related
Interview angle 5
- “What happens to an unhandled exception in FastAPI?” - a 500 with no useful body, logged server-side. Register a handler for
Exceptionto return a consistent shape with a trace id — and never putstr(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
HTTPExceptionin 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
Exceptioncatch everything?” - no.HTTPExceptionandRequestValidationErrorhave 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 satisfyresponse_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.