Consistent Error Handling

Published by Programming Line in FastAPI. Last updated .

FastAPI error handling is the layer that turns Python failures into predictable HTTP responses. The outcome is simple: every client receives the same envelope for the same class of failure, while logs keep the diagnostic details that should never leave the service. In this lesson, a consistent error response means a stable status code, a machine-readable error code, a human-readable message, and optional field-level details. The point is not to hide errors; it is to make errors explicit, testable, documented, and safe.

This belongs in the Testing and Documentation section because error responses are part of the public API. A client generator, integration test, frontend form, and operations dashboard all depend on the same behavior. If one endpoint returns {"detail": "not found"}, another returns a raw stack trace, and validation errors use a third shape, clients need endpoint-specific branches. FastAPI gives you the hooks to avoid that: HTTPException, request validation exceptions, application exception handlers, response models, and OpenAPI metadata.

Purpose and Outcome

The goal is to design one error envelope and translate failures into it at the boundary between application code and HTTP. A good envelope is small enough to be stable but expressive enough for real clients. This lesson uses this shape:

from pydantic import BaseModel, Field

class ErrorItem(BaseModel):
    loc: list[str | int] = Field(default_factory=list)
    message: str
    type: str

class ErrorEnvelope(BaseModel):
    code: str
    message: str
    request_id: str
    details: list[ErrorItem] = Field(default_factory=list)

code is for machines, such as USER_NOT_FOUND or VALIDATION_ERROR. message is safe text for humans. request_id connects the client-visible failure to server logs. details holds structured validation items when they are useful. The envelope deliberately excludes stack traces, SQL text, file paths, internal primary keys, and secret configuration.

How FastAPI Produces Errors

FastAPI sits on Starlette, so request handling flows through ASGI middleware, routing, dependency resolution, body parsing, validation, endpoint execution, response serialization, and exception handling. When an exception escapes an endpoint or dependency, Starlette looks for a registered exception handler. FastAPI registers default handlers for HTTPException and request validation errors. You can replace or extend those handlers with @app.exception_handler(ExceptionType).

There are three common categories. First, client input errors happen before your endpoint body runs: path parameters, query parameters, headers, cookies, and JSON bodies are parsed and validated. FastAPI raises RequestValidationError. Second, application decisions happen inside your code: a missing user, duplicate email, forbidden transition, or insufficient permission. You can raise domain exceptions and translate them centrally. Third, unexpected defects and infrastructure failures happen when code breaks, a database is unavailable, or a dependency times out. Those should become a generic internal error response while preserving the full exception in logs.

API Anatomy

HTTPException is useful for small applications because it carries status_code, detail, and optional headers. However, using it everywhere couples domain logic to HTTP. A cleaner design raises domain exceptions from services and converts them in one handler. The handler receives the Request and exception, then returns a JSONResponse. The status code belongs at this translation point.

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field

app = FastAPI(title="Consistent Error Handling")

class ErrorItem(BaseModel):
    loc: list[str | int] = Field(default_factory=list)
    message: str
    type: str

class ErrorEnvelope(BaseModel):
    code: str
    message: str
    request_id: str
    details: list[ErrorItem] = Field(default_factory=list)

class DomainError(Exception):
    status_code = 400
    code = "DOMAIN_ERROR"
    message = "The request cannot be completed."

class UserNotFound(DomainError):
    status_code = 404
    code = "USER_NOT_FOUND"
    message = "User was not found."

@app.exception_handler(DomainError)
async def domain_error_handler(request: Request, exc: DomainError):
    request_id = request.headers.get("x-request-id", "local")
    body = ErrorEnvelope(code=exc.code, message=exc.message, request_id=request_id)
    return JSONResponse(status_code=exc.status_code, content=body.model_dump())

The handler is asynchronous because exception handlers participate in the same ASGI flow as endpoints. The example reads an incoming x-request-id for clarity; production systems usually create one in middleware if the client did not provide it.

Example 1: Translating a Domain Error

This first complete application keeps the endpoint small. The service function raises UserNotFound; the handler decides how that appears over HTTP.

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field

app = FastAPI(title="Error Example 1")

class ErrorItem(BaseModel):
    loc: list[str | int] = Field(default_factory=list)
    message: str
    type: str

class ErrorEnvelope(BaseModel):
    code: str
    message: str
    request_id: str
    details: list[ErrorItem] = Field(default_factory=list)

class DomainError(Exception):
    status_code = 400
    code = "DOMAIN_ERROR"
    message = "The request cannot be completed."

class UserNotFound(DomainError):
    status_code = 404
    code = "USER_NOT_FOUND"
    message = "User was not found."

USERS = {1: {"id": 1, "name": "Ada"}}

@app.exception_handler(DomainError)
async def domain_error_handler(request: Request, exc: DomainError):
    request_id = request.headers.get("x-request-id", "local")
    envelope = ErrorEnvelope(code=exc.code, message=exc.message, request_id=request_id)
    return JSONResponse(status_code=exc.status_code, content=envelope.model_dump())

def load_user(user_id: int) -> dict[str, object]:
    try:
        return USERS[user_id]
    except KeyError as exc:
        raise UserNotFound() from exc

@app.get("/users/{user_id}")
def get_user(user_id: int):
    return load_user(user_id)

client = TestClient(app)
response = client.get("/users/2", headers={"x-request-id": "req-123"})
print(response.status_code)
print(response.json())

The deterministic output is status 404 and a body containing code USER_NOT_FOUND, safe message User was not found., request id req-123, and an empty details list. The client does not see the Python KeyError or the contents of the in-memory store.

Example 2: Normalizing Validation Errors

FastAPI’s default validation body is already structured, but many teams still wrap it so every failure has the same top-level fields. The key is to preserve useful locations and messages without leaking the entire submitted payload.

from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field

app = FastAPI(title="Error Example 2")

class ErrorItem(BaseModel):
    loc: list[str | int] = Field(default_factory=list)
    message: str
    type: str

class ErrorEnvelope(BaseModel):
    code: str
    message: str
    request_id: str
    details: list[ErrorItem] = Field(default_factory=list)

class CreateUser(BaseModel):
    email: str = Field(pattern=r"^[^@]+@[^@]+\.[^@]+$")
    age: int = Field(ge=13)

@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
    request_id = request.headers.get("x-request-id", "local")
    details = [
        ErrorItem(loc=list(error["loc"]), message=error["msg"], type=error["type"])
        for error in exc.errors()
    ]
    envelope = ErrorEnvelope(
        code="VALIDATION_ERROR",
        message="Request validation failed.",
        request_id=request_id,
        details=details,
    )
    return JSONResponse(status_code=422, content=envelope.model_dump())

@app.post("/users", status_code=201)
def create_user(payload: CreateUser):
    return {"id": 1, "email": payload.email, "age": payload.age}

client = TestClient(app)
response = client.post("/users", json={"email": "bad", "age": 10})
print(response.status_code)
print(response.json()["code"])
print([item["loc"] for item in response.json()["details"]])

The output is 422, VALIDATION_ERROR, and locations for body.email and body.age. Exact validation message wording can vary with Pydantic behavior, so tests should assert stable facts such as status, code, and field locations unless your application owns the wording.

Example 3: Handling Unexpected Failures Safely

Unexpected exceptions should be logged with traceback information but returned as a generic error. During tests, TestClient raises server exceptions by default, so set raise_server_exceptions=False when you want to inspect the HTTP 500 response.

import logging
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("api.errors")
app = FastAPI(title="Error Example 3")

class ErrorItem(BaseModel):
    loc: list[str | int] = Field(default_factory=list)
    message: str
    type: str

class ErrorEnvelope(BaseModel):
    code: str
    message: str
    request_id: str
    details: list[ErrorItem] = Field(default_factory=list)

@app.exception_handler(Exception)
async def unexpected_error_handler(request: Request, exc: Exception):
    request_id = request.headers.get("x-request-id", "local")
    logger.exception("Unhandled error for request_id=%s", request_id)
    envelope = ErrorEnvelope(
        code="INTERNAL_SERVER_ERROR",
        message="An unexpected error occurred.",
        request_id=request_id,
    )
    return JSONResponse(status_code=500, content=envelope.model_dump())

@app.get("/reports/{report_id}")
def get_report(report_id: int):
    if report_id == 13:
        raise RuntimeError("database password appeared in a lower layer")
    return {"id": report_id, "status": "ready"}

client = TestClient(app, raise_server_exceptions=False)
response = client.get("/reports/13", headers={"x-request-id": "req-500"})
print(response.status_code)
print(response.json())

The response is a 500 envelope with INTERNAL_SERVER_ERROR and request id req-500. The unsafe exception text appears only in server logs. In a real application, that log should go to a restricted sink with retention and redaction rules.

Design Choices and Trade-offs

One design choice is whether to raise HTTPException directly or raise domain exceptions. Direct HTTPException is concise for authentication checks, simple prototypes, or router-level conditions. Domain exceptions are better when the same service function is used by HTTP endpoints, background tasks, tests, or command-line jobs. They also make it harder for internal code to accidentally choose a public status code.

A second choice is how specific error codes should be. INVALID_INPUT is easy to maintain but forces clients to parse details. EMAIL_ALREADY_REGISTERED supports precise user-interface behavior but becomes a public contract that must be documented and kept stable. Use specific codes when clients can take distinct action; otherwise keep the taxonomy smaller.

A third choice is whether to include request_id, trace_id, or both. request_id is usually a service-level correlation value. trace_id often comes from distributed tracing. If you expose either, validate length and character set in middleware so clients cannot inject misleading log content.

Failure Modes and Troubleshooting

Symptom: a test expecting a JSON 500 response raises RuntimeError instead. Cause: TestClient defaults to re-raising server exceptions. Diagnose: check the client construction in the test. Correct: use TestClient(app, raise_server_exceptions=False) for tests that assert the 500 envelope.

Symptom: validation errors return the default {"detail": ...} shape while domain errors use your envelope. Cause: only DomainError has a custom handler. Diagnose: submit an invalid body and inspect the status and top-level keys. Correct: register a handler for RequestValidationError and map exc.errors() into your details list.

Symptom: an endpoint returns 200 with an error object in the body. Cause: application code returned an error dictionary instead of raising an exception or returning a proper JSONResponse. Diagnose: search for returned dictionaries containing keys like error or code. Correct: raise a domain exception and let the central handler set the status code.

Symptom: stack traces or database errors appear in client responses. Cause: debug mode, broad conversion of exception text into message, or a catch-all handler that exposes str(exc). Diagnose: force a known runtime error in a non-production test environment. Correct: use a generic public message for unexpected exceptions and log the exception server-side.

Security, Performance, and Reliability

Error handlers run on failure paths, so they must be dependable. Avoid extra database calls from a handler just to decorate the response; a failing database can turn one error into another. Keep the envelope construction cheap and deterministic. If you localize messages, load message catalogs at startup rather than performing network lookups during exception handling.

Security depends on deliberate omission. Public responses should not include stack traces, SQL fragments, secrets, access tokens, internal hostnames, or authorization policy internals. Reliability depends on classification: client errors such as 400, 401, 403, 404, and 422 should be counted separately from server errors such as 500 and dependency errors such as 503. That separation lets tests, dashboards, and alerts distinguish bad input from broken service behavior.

Hands-on Lab

Prerequisites: a Python environment with FastAPI installed and the ability to run tests with pytest. Create a small app module named main.py and a test module named test_errors.py. The lab verifies that domain, validation, and unexpected errors all use the same envelope.

  1. Define ErrorItem and ErrorEnvelope Pydantic models in main.py.
  2. Create a DomainError base class and at least one subclass, such as WidgetNotFound with status 404 and code WIDGET_NOT_FOUND.
  3. Register exception handlers for DomainError, RequestValidationError, and Exception.
  4. Add POST /widgets with a body model that requires a non-empty name and GET /widgets/{widget_id} that raises WidgetNotFound for an unknown id.
  5. Write tests that assert the top-level response keys are exactly code, message, request_id, and details for all three failure categories.
  6. For the unexpected error test, construct TestClient(app, raise_server_exceptions=False).

Verification: run pytest. You should see passing tests for a 404 domain error, a 422 validation error, and a 500 unexpected error. Also open /docs and confirm that documented error responses are present for endpoints where you added responses={...}. Cleanup: remove the temporary modules or keep them as regression tests. If the handler changes break clients, rollback by restoring the previous envelope fields first, then reintroduce new optional fields after clients are updated.

Assessment Exercises

  • Design an error code taxonomy for user registration with duplicate email, weak password, disabled tenant, and email provider outage. Which codes should clients handle separately?
  • Write a test that proves an invalid request body includes field locations but does not echo the submitted password.
  • Refactor an endpoint that returns {"error": "missing"} with status 200 so it raises a domain exception and returns the shared envelope.
  • Explain when 404 is more appropriate than 403 for a missing resource, and how that choice affects information disclosure.
  • Add OpenAPI response metadata for one endpoint so generated documentation shows the envelope schema for 404 and 422.

Summary

Consistent FastAPI error handling is built from a stable envelope, precise exception categories, and central handlers. Validation errors come from FastAPI before endpoint logic runs, domain errors come from your application decisions, and unexpected failures should be logged while returning a generic response. The important engineering choice is where translation happens: keep domain logic independent, convert to HTTP at the API boundary, and test the error contract as carefully as successful responses.