Error Handling and Global Exceptions in FastAPI

Centralized error handling means every failure — a validation error, a raised HTTPException, a domain rule violation, or an unexpected crash — is converted into one predictable, machine-readable response by a small set of registered handlers rather than scattered try/except blocks.

A consistent error contract is part of the public surface of your API, which is why it belongs in Core Architecture and Routing Patterns alongside routing and lifecycle. The handlers are registered in the application factory, they rely on the correlation ID set in middleware, and they feed the observability story covered in Observability and Tracing.

Exceptions funneled into one consistent error envelope Four exception sources — validation errors, HTTP exceptions, domain errors, and uncaught exceptions — pass through registered handlers and converge into a single JSON error envelope with a stable shape. RequestValidationError HTTPException DomainError (yours) Uncaught Exception Registered handlers Error envelope error: code message: string request_id: uuid
Every exception class converges through registered handlers into one stable JSON envelope, so clients parse a single error format across the whole API.

Core Mechanics: Handler Registration

FastAPI lets you register a handler for any exception type. When an exception propagates out of a route, FastAPI matches it against the registered handlers — most specific first — and invokes the matching one to build the response. This is strictly better than per-route try/except, because coverage is guaranteed and the contract lives in one place.

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse


class DomainError(Exception):
    def __init__(self, code: str, message: str, status_code: int = 400) -> None:
        self.code, self.message, self.status_code = code, message, status_code


def register_error_handlers(app: FastAPI) -> None:
    @app.exception_handler(DomainError)
    async def handle_domain_error(request: Request, exc: DomainError) -> JSONResponse:
        # Map a typed domain error to the shared envelope and its status code.
        return JSONResponse(
            status_code=exc.status_code,
            content={"error": exc.code, "message": exc.message,
                     "request_id": request.headers.get("x-request-id", "")},
        )

Production Implementation: Normalizing Validation and Unknowns

Two handlers complete the picture: one normalizes FastAPI's validation errors into your envelope, and one catches everything unforeseen so no raw traceback ever reaches a client.

from fastapi.exceptions import RequestValidationError
import logging

logger = logging.getLogger("api.errors")


def register_catch_all(app: FastAPI) -> None:
    @app.exception_handler(RequestValidationError)
    async def handle_validation(request: Request, exc: RequestValidationError):
        return JSONResponse(
            status_code=422,
            content={"error": "validation_error", "message": "Invalid request.",
                     "details": exc.errors()},
        )

    @app.exception_handler(Exception)
    async def handle_unexpected(request: Request, exc: Exception) -> JSONResponse:
        # Full context internally; opaque, stable envelope externally.
        logger.error("unhandled", exc_info=exc, extra={"path": request.url.path})
        return JSONResponse(
            status_code=500,
            content={"error": "internal_server_error", "message": "Unexpected error."},
        )

The end-to-end implementation, including status-code mapping tables, is detailed in Global Exception Handlers for Consistent API Responses.

Async and Performance Notes

Exception handlers run on the event loop, so keep them light — building a JSON response and emitting a log line is fine, but do not perform additional I/O such as a database write inside a handler on the hot failure path. If you need to persist error events, hand them to a background task rather than blocking the response, as covered in Background Task Processing.

Testing Strategy

Assert both the status code and the envelope shape so the contract is locked:

def test_domain_error_envelope(client):
    resp = client.get("/orders/does-not-exist")
    assert resp.status_code == 404
    body = resp.json()
    assert set(body) >= {"error", "message"}   # Stable keys clients can rely on.
    assert "traceback" not in body              # Never leak internals.

Failure Modes and Debugging

  • Inconsistent validation shape. Without a RequestValidationError handler, validation errors return a different format than your other errors. Normalize them.
  • Swallowed errors. A catch-all that returns 200 hides failures from monitoring. Always preserve the correct status code.
  • Leaked internals. Returning str(exc) can expose secrets or SQL. Return a fixed message and log the detail.
  • Handler ordering. A handler registered for Exception will catch subclasses unless a more specific handler exists; register specific handlers too.