Global Exception Handlers for Consistent API Responses
Key takeaways:
- A default FastAPI app returns three different error shapes, one of which is plain text, not JSON.
- One envelope with a machine-readable
codelets clients write one parser and branch on the code. - Register against
starlette.exceptions.HTTPExceptionso Starlette's own 404 and 405 are covered too. - One handler on a
DomainErrorbase class covers every subclass, because Starlette walks the MRO. - Log the real cause for unexpected errors and return an opaque message — exception text leaks internals.
This guide implements the design argued for in Error Handling and Global Exceptions. It covers the whole error surface; two neighbouring decisions have their own guides — the shape of validation errors specifically in Customising Validation Error Responses, and whether to raise HTTPException or a domain type in HTTPException vs Custom Exception Classes.
The Problem This Solves
Client teams do not experience your error handling as a design; they experience it as the number of shapes they must parse before the UI can display "something went wrong" correctly. Most FastAPI services ship more shapes than their authors realise, because the defaults are reasonable individually and inconsistent collectively.
So rather than describe them, here is a bare FastAPI app — no handlers registered — answering six different failures:
$ GET /without-handlers
200 OK
[
{
"case": "a modelled domain failure",
"request": "GET /orders/404",
"status": 500,
"body": "Internal Server Error"
},
{
"case": "request body fails validation",
"request": "POST /orders",
"status": 422,
"body": {
"detail": [
{
"type": "string_too_short",
"loc": [
"body",
"sku"
],
"msg": "String should have at least 3 characters",
"input": "A",
"ctx": {
"min_length": 3
}
},
{
"type": "greater_than",
"loc": [
"body",
"quantity"
],
"msg": "Input should be greater than 0",
"input": 0,
"ctx": {
"gt": 0
}
}
]
}
},
{
"case": "a route raising HTTPException",
"request": "GET /orders/1/legacy",
"status": 403,
"body": {
"detail": "You do not own this order"
}
},
{
"case": "no route matches at all",
"request": "GET /nope",
"status": 404,
"body": {
"detail": "Not Found"
}
},
{
"case": "an unhandled bug in the handler",
"request": "GET /crash",
"status": 500,
"body": "Internal Server Error"
}
]
Count the shapes a client must handle. detail as a list of objects. detail as a string. And Internal Server Error as plain text, not JSON at all — so response.json() raises, and a naive client turns a server error into a client-side crash. A modelled business failure that should have been a clean 404 came back as an opaque 500 for the same reason: nothing knew what to do with the exception type.
Why It Happens
Starlette keeps a dict on the application mapping exception classes to handler callables. When an exception escapes a route, ExceptionMiddleware looks for a handler by walking type(exc).__mro__ and taking the first class present in that dict. This is why one handler on a base class covers an entire hierarchy of subclasses, and why a handler for a specific subclass wins over one for its parent.
FastAPI pre-populates that dict with exactly two entries: one for HTTPException, which renders {"detail": ...}, and one for RequestValidationError, which renders {"detail": [...]}. Those are the two JSON shapes above. They are registered by the same mechanism you use, which is what makes them replaceable rather than something you must work around.
The plain-text 500 comes from somewhere else entirely. ServerErrorMiddleware sits at the very outside of the stack as a last resort for anything the exception middleware did not handle. It is not part of the handler dict, it returns PlainTextResponse("Internal Server Error"), and it re-raises so the server can log the traceback. That outermost position is also why it cannot be reached by a handler registered inside it — the only way to control the 500 body is to register a handler for Exception, which FastAPI special-cases into that outer layer.
Starlette also raises HTTPException itself, for unmatched paths and unsupported methods. Those instances are starlette.exceptions.HTTPException, and since fastapi.HTTPException is a subclass of it, a handler registered on the FastAPI class is never found when Starlette raises the parent. Registering on the Starlette class covers both.
The Fix
1. Decide the envelope, once
def envelope(request: Request, code: str, message: str, **extra: Any) -> dict[str, Any]:
"""The single response shape every failure in this API returns."""
body = {
"error": {
"code": code,
"message": message,
"request_id": request.headers.get("x-request-id", "none"),
}
}
if extra:
body["error"]["details"] = extra
return body
Everything nests under one error key, so a client can test for its presence rather than inferring failure from the status code. code is stable and machine-readable — clients branch on it, and it must never change once published, even if message is reworded. message is human-readable and safe to display. request_id ties the response to your logs; it comes from the tracing middleware, and it is the field that turns a support screenshot into a log query.
2. One handler per category, not per error
Give business failures a base class that carries its own code and status:
class DomainError(Exception):
"""One base class for expected business failures, carrying its own code and status."""
code = "domain_error"
status_code = 400
def __init__(self, **context: Any) -> None:
self.context = context
class OrderNotFound(DomainError):
code = "order_not_found"
status_code = 404
class InsufficientStock(DomainError):
code = "insufficient_stock"
status_code = 409
Then register four handlers that between them cover the entire error surface:
@enveloped.exception_handler(DomainError)
async def on_domain_error(request: Request, exc: DomainError) -> JSONResponse:
# One handler covers every subclass: Starlette walks type(exc).__mro__ to find it.
return JSONResponse(
status_code=exc.status_code,
content=envelope(request, exc.code, exc.code.replace("_", " "), **exc.context),
)
@enveloped.exception_handler(StarletteHTTPException)
async def on_http_exception(request: Request, exc: StarletteHTTPException) -> JSONResponse:
# Covers routes that still raise HTTPException, and Starlette's own 404 and 405.
codes = {403: "forbidden", 404: "not_found", 405: "method_not_allowed"}
return JSONResponse(
status_code=exc.status_code,
content=envelope(request, codes.get(exc.status_code, "http_error"), str(exc.detail)),
)
@enveloped.exception_handler(Exception)
async def on_unexpected(request: Request, exc: Exception) -> JSONResponse:
# Log the real cause server-side; return nothing about it to the caller.
logger.error("unhandled error", exc_info=exc, extra={"path": request.url.path})
return JSONResponse(
status_code=500,
content=envelope(request, "internal_error", "An unexpected error occurred."),
)
The DomainError handler is the one that scales. Adding a new business failure costs one class with two attributes and no handler at all — a new error type never requires touching the error-handling code, which is what stops teams from reaching for HTTPException out of convenience.
The Exception handler is deliberately incurious. It logs with exc_info so the traceback reaches your log store, and returns a fixed string. Exception messages routinely contain connection strings, SQL fragments, file paths, and internal hostnames, and an error path is the last place you want to be improvising about disclosure.
3. The same six failures, with handlers registered
$ GET /with-envelope
200 OK
[
{
"case": "a modelled domain failure",
"request": "GET /orders/404",
"status": 404,
"body": {
"error": {
"code": "order_not_found",
"message": "order not found",
"request_id": "none",
"details": {
"order_id": 404
}
}
}
},
{
"case": "another domain failure, different status",
"request": "POST /orders",
"status": 409,
"body": {
"error": {
"code": "insufficient_stock",
"message": "insufficient stock",
"request_id": "none",
"details": {
"sku": "ABC",
"requested": 500,
"available": 100
}
}
}
},
{
"case": "request body fails validation",
"request": "POST /orders",
"status": 422,
"body": {
"error": {
"code": "validation_error",
"message": "The request body is invalid.",
"request_id": "none",
"details": {
"sku": "String should have at least 3 characters",
"quantity": "Input should be greater than 0"
}
}
}
},
{
"case": "a route raising HTTPException",
"request": "GET /orders/1/legacy",
"status": 403,
"body": {
"error": {
"code": "forbidden",
"message": "You do not own this order",
"request_id": "none"
}
}
},
{
"case": "no route matches at all",
"request": "GET /nope",
"status": 404,
"body": {
"error": {
"code": "not_found",
"message": "Not Found",
"request_id": "none"
}
}
},
{
"case": "an unhandled bug in the handler",
"request": "GET /crash",
"status": 500,
"body": {
"error": {
"code": "internal_error",
"message": "An unexpected error occurred.",
"request_id": "none"
}
}
}
]
Same application, same six failures, one shape. The domain failures now carry their true status codes — 404 and 409 rather than a blanket 500 — and their details carry structured context the client can act on: available: 100 is enough to tell a user how many they can actually order.
Two subtler wins are visible. The unmatched route at /nope produced the envelope, which only happens because the handler is registered on the Starlette class. And the ZeroDivisionError at /crash became a JSON 500 rather than plain text, so a client's response.json() succeeds on every path and there is no branch where the error handling itself fails.
Verification
Contract tests are what keep this from drifting back apart:
@pytest.mark.parametrize("method,path,body,status", [
("GET", "/orders/404", None, 404),
("POST", "/orders", {"sku": "A", "quantity": 0}, 422),
("GET", "/orders/1/legacy", None, 403),
("GET", "/nope", None, 404),
("GET", "/crash", None, 500),
])
def test_every_failure_uses_one_envelope(client, method, path, body, status):
resp = client.request(method, path, json=body)
assert resp.status_code == status
assert resp.headers["content-type"].startswith("application/json")
error = resp.json()["error"]
assert {"code", "message", "request_id"} <= set(error)
def test_internal_details_never_leak(client):
resp = client.get("/crash")
assert "ZeroDivisionError" not in resp.text
assert "division by zero" not in resp.text
def test_every_domain_error_has_a_unique_code():
codes = [c.code for c in all_subclasses(DomainError)]
assert len(codes) == len(set(codes))
The parametrised test is the valuable one, because it fails the moment someone adds a route that raises something unmapped. The content-type assertion specifically catches the plain-text regression, which is otherwise easy to miss since the status code is correct.
Trade-offs and When Not To
The largest cost is one the transcript cannot show: your OpenAPI schema still describes the old shapes. FastAPI generates 422 documentation from HTTPValidationError and knows nothing about your envelope, so generated clients get typed models that do not match what the server sends. Fix it by declaring responses={...} on your routes or post-processing the schema in a custom openapi(); either way it is manual work that will drift unless a test compares a real error response against the documented schema.
Wrapping everything in error also costs a little for the simplest consumers, and it is genuinely worth considering whether to adopt RFC 9457 application/problem+json instead of a bespoke envelope. If your API is public, or crosses organisational boundaries, the standard shape is better — clients may already have a parser for it, and type, title, status, and detail cover most of what a custom envelope invents. The reasoning on this page applies unchanged; only the key names differ.
Finally, keep the handlers trivial. A handler that raises produces a bare 500 from the outermost middleware and loses your envelope precisely when you most need it, so no database writes, no outbound calls, and no clever serialisation on the error path. Anything expensive — an audit record, an alert — belongs on a queue, not inline.
FAQ
What does FastAPI return by default for an unhandled exception?
The plain-text body Internal Server Error with a 500 status and a text/plain content type. A verified run shows it alongside the JSON shapes returned for validation and HTTPException failures, which is the point: a client parsing JSON gets a parse error on top of the original failure.
How many error shapes does a default FastAPI app actually return?
Three, from four sources. Validation failures return detail as a list of error objects, HTTPException and Starlette's own 404 return detail as a string, and an unhandled exception returns plain text. A client must handle all three to display errors reliably.
Do I need to register a handler for HTTPException as well as my own exceptions?
Yes, if you want one shape everywhere. Register against starlette.exceptions.HTTPException rather than fastapi.HTTPException, because Starlette raises its own instances for unmatched routes and unsupported methods, and a handler registered on the FastAPI subclass will not be found for those.
Will a handler registered for Exception catch HTTPException too?
In principle, but it does not need to. Starlette resolves handlers by walking the exception's method resolution order and choosing the most specific registered match, so a registered HTTPException handler wins. The Exception handler stays as the last resort for genuinely unexpected failures.
Should the error body ever include the exception message?
Not for unexpected errors. Log the real cause with exc_info server-side and return a fixed opaque message, because exception text routinely contains connection strings, SQL, and internal hostnames. Modelled domain errors are different: their messages are written for clients and are safe to return.
Related Reading
- Up to the topic: Error Handling and Global Exceptions, for the rationale behind a single error contract.
- for shaping the 422 body specifically, including its field-level detail: Customising Validation Error Responses.
- for whether a given failure should raise
HTTPExceptionor a domain type: HTTPException vs Custom Exception Classes. - for the correlation ID that fills the
request_idfield: Implementing Custom Middleware for Request Tracing. - for why a handler cannot catch an exception raised in middleware registered outside it: Middleware Execution Order.