Global Exception Handlers for Consistent API Responses
Key takeaways:
- Pick one error envelope and return it for every failure, discriminated by an
errorcode. - Map typed domain errors to status codes in one place, not per route.
- Override the validation handler so
422responses match your envelope. - Add a catch-all
Exceptionhandler that logs internally and returns an opaque message. - Lock the shape with tests so the contract cannot silently drift.
This guide implements the design from Error Handling and Global Exceptions. Start there for the rationale; here we build the handlers end to end.
The Problem This Solves
When each route invents its own error format, client teams must handle a different payload per endpoint, and a refactor can silently change the shape. Worse, an uncaught exception returns a raw 500 with a traceback. Global handlers fix both: a single envelope, mapped centrally, with internals kept server-side.
Prerequisites
- A FastAPI app constructed via a factory, so handlers register in one place.
- A middleware that assigns a correlation ID (see Implementing Custom Middleware for Request Tracing).
Step-by-Step Implementation
1. Define the envelope and a domain error
# app/errors.py
from dataclasses import dataclass
@dataclass
class DomainError(Exception):
code: str # Stable, machine-readable: "order_not_found"
message: str # Human-readable, safe to show clients.
status_code: int = 400
2. Map status codes in one table
# A single source of truth for which domain conditions map to which HTTP status.
STATUS_BY_CODE = {
"order_not_found": 404,
"insufficient_funds": 402,
"rate_limited": 429,
}
3. Register the handlers in the factory
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
import logging
logger = logging.getLogger("api.errors")
def register_error_handlers(app: FastAPI) -> None:
@app.exception_handler(DomainError)
async def on_domain(request: Request, exc: DomainError) -> JSONResponse:
status = STATUS_BY_CODE.get(exc.code, exc.status_code)
return JSONResponse(status, _envelope(request, exc.code, exc.message))
@app.exception_handler(RequestValidationError)
async def on_validation(request: Request, exc: RequestValidationError):
body = _envelope(request, "validation_error", "Invalid request.")
body["details"] = exc.errors() # Field-level detail in the same shape.
return JSONResponse(422, body)
@app.exception_handler(Exception)
async def on_unexpected(request: Request, exc: Exception) -> JSONResponse:
logger.error("unhandled", exc_info=exc, extra={"path": request.url.path})
return JSONResponse(500, _envelope(request, "internal_server_error",
"An unexpected error occurred."))
4. Build the envelope helper
def _envelope(request: Request, code: str, message: str) -> dict:
return {
"error": code,
"message": message,
"request_id": request.headers.get("x-request-id", ""),
}
Edge Cases and Gotchas
- JSONResponse positional arguments.
JSONResponse(status, content)relies on argument order; prefer keyword arguments if you find it ambiguous. - Errors raised inside handlers. If an exception handler itself raises, the server falls back to a bare 500. Keep handlers trivial and side-effect-free.
- Background work on the failure path. Persisting an audit record should be queued, not awaited inline, so a logging outage cannot slow error responses.
Verification
def test_validation_uses_shared_envelope(client):
resp = client.post("/orders", json={}) # Missing required fields.
assert resp.status_code == 422
body = resp.json()
assert body["error"] == "validation_error"
assert {"error", "message", "details"} <= set(body)
def test_unexpected_is_opaque(client, monkeypatch):
monkeypatch.setattr("app.services.charge", _raise_boom)
resp = client.post("/orders", json=valid_payload)
assert resp.status_code == 500
assert "boom" not in resp.text # Internal detail never leaks.
Related Reading
- Up to the topic: Error Handling and Global Exceptions.
- Related patterns: Implementing Custom Middleware for Request Tracing for the correlation ID, and Observability and Tracing for surfacing errors in dashboards.