Global Exception Handlers for Consistent API Responses

Key takeaways:

  • Pick one error envelope and return it for every failure, discriminated by an error code.
  • Map typed domain errors to status codes in one place, not per route.
  • Override the validation handler so 422 responses match your envelope.
  • Add a catch-all Exception handler 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

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.