Philipp Lehmann @PhilippTheServer

An Exception Hierarchy and One Handler for FastAPI Error Responses

Bare HTTPExceptions scattered across a codebase produce inconsistent payloads and lose debugging context.

The problem

HTTPException is the obvious tool for signalling an error from inside a FastAPI route, and it is usually the first thing reached for:

@router.get("/orders/{order_id}")
def get_order(order_id: int):
    order = find_order(order_id)
    if order is None:
        raise HTTPException(status_code=404, detail="Order not found")
    if order.customer_id != current_customer_id():
        raise HTTPException(status_code=403, detail="Not your order")
    return order

This is fine in isolation, and it stays fine for exactly as long as error handling lives only in routes. It stops being fine the moment business logic that raises errors moves into a service function called from several different routes — HTTPException is an HTTP-layer concept, so a service function that raises it has to know it is being called from an HTTP context, which breaks the moment the same service function gets reused from a background job or a CLI script with no HTTP request in sight.

The more common failure, though, is inconsistency rather than a layering violation. Different developers, or the same developer on different days, reach for HTTPException with different detail shapes — sometimes a string, sometimes a dict, sometimes a dict with a code field and sometimes without:

raise HTTPException(status_code=404, detail="Order not found")
raise HTTPException(status_code=404, detail={"message": "User not found", "code": "USER_404"})
raise HTTPException(status_code=409, detail={"error": "duplicate_email"})

A client trying to distinguish “order not found” from “user not found” programmatically, rather than by string-matching an English sentence, cannot do it reliably, because only some of these errors carry a machine-readable code at all, and the ones that do disagree on the field name. Worse, the actual context that would help debug the failure — which order ID was requested, which customer made the request — dies at the raise statement, because detail is whatever string or dict got typed in that one call site, not something structured the way a log line would be.

Working through it

Domain errors should not know about HTTP

The layering fix is to define exceptions that describe what went wrong in domain terms — OrderNotFoundError, NotOrderOwnerError — with no reference to status codes at all, and let something at the edge of the application translate a domain error into an HTTP response. This is what makes the same service function reusable outside a request context: a background job that raises OrderNotFoundError can catch it and log it without ever having heard of a 404.

class AppError(Exception):
    """Base for every domain error the application raises on purpose."""

    def __init__(self, message: str, *, context: dict | None = None) -> None:
        super().__init__(message)
        self.message = message
        self.context = context or {}


class NotFoundError(AppError):
    pass


class OrderNotFoundError(NotFoundError):
    def __init__(self, order_id: int) -> None:
        super().__init__(f"Order {order_id} not found", context={"order_id": order_id})


class ForbiddenError(AppError):
    pass

The context dict is the part HTTPException had no place for: structured data about this specific failure, kept separate from the human-readable message, available to a handler for logging even if it is deliberately not echoed back to the client.

One handler, one mapping, one place that decides the status code

FastAPI’s add_exception_handler lets you register a handler for a base class and have it catch every subclass — this is the single point where “what HTTP status does this kind of domain error deserve” gets decided, once, instead of once per call site.

_STATUS_BY_ERROR_TYPE: dict[type[AppError], int] = {
    NotFoundError: 404,
    ForbiddenError: 403,
    ConflictError: 409,
}


def _status_for(error: AppError) -> int:
    for error_type, status in _STATUS_BY_ERROR_TYPE.items():
        if isinstance(error, error_type):
            return status
    return 500


async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
    status = _status_for(exc)
    logger.warning(
        "domain error: %s", exc.message, extra={"context": exc.context, "path": request.url.path}
    )
    return JSONResponse(
        status_code=status,
        content={
            "success": False,
            "error": {"type": type(exc).__name__, "message": exc.message},
        },
    )

Adding a new kind of domain error to an existing category — a second NotFoundError subclass for a different resource — needs no change to this handler at all; it is caught by isinstance against the base it already inherits from. Adding a genuinely new category, with its own status code, is one new line in _STATUS_BY_ERROR_TYPE, not a new except block scattered across routes.

Deciding what a client is allowed to see

exc.context deliberately never appears in the response body in the handler above — only exc.message does. This is a considered choice, not an oversight: context can carry internal identifiers or values that are fine in a log line read by the team but are not something to hand to an untrusted client, particularly for errors further up the hierarchy that were not written with a public response in mind. A field-by-field decision about what crosses that boundary belongs in the handler, in one place, rather than in the judgement of whoever writes the next raise statement.

HTTPException still has a place

This hierarchy is for domain errors — things the business logic itself decides are wrong. Errors that are inherently about the HTTP layer and nothing else — a malformed Authorization header, a request that violates a rate limit before it reaches any business logic — are reasonably still raised as HTTPException directly from dependencies, because there is no domain concept underneath them to model. The rule that keeps this from sliding back into inconsistency is scope, decided once: HTTPException is for the transport layer, AppError and its subclasses are for everything a service function decides.

The solution

app/
  main.py
  errors.py
  orders/
    router.py
    service.py
tests/
  test_errors.py
requirements.txt
# app/errors.py
from __future__ import annotations

import logging

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

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


class AppError(Exception):
    def __init__(self, message: str, *, context: dict | None = None) -> None:
        super().__init__(message)
        self.message = message
        self.context = context or {}


class NotFoundError(AppError):
    pass


class ForbiddenError(AppError):
    pass


class ConflictError(AppError):
    pass


class OrderNotFoundError(NotFoundError):
    def __init__(self, order_id: int) -> None:
        super().__init__(f"Order {order_id} not found", context={"order_id": order_id})


class NotOrderOwnerError(ForbiddenError):
    def __init__(self, order_id: int, customer_id: str) -> None:
        super().__init__(
            "This order does not belong to the requesting customer",
            context={"order_id": order_id, "customer_id": customer_id},
        )


_STATUS_BY_ERROR_TYPE: dict[type[AppError], int] = {
    NotFoundError: 404,
    ForbiddenError: 403,
    ConflictError: 409,
}


def _status_for(error: AppError) -> int:
    for error_type, status in _STATUS_BY_ERROR_TYPE.items():
        if isinstance(error, error_type):
            return status
    return 500


async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
    status = _status_for(exc)
    logger.warning(
        "domain error: %s", exc.message, extra={"context": exc.context, "path": request.url.path}
    )
    return JSONResponse(
        status_code=status,
        content={
            "success": False,
            "error": {"type": type(exc).__name__, "message": exc.message},
        },
    )


def register_error_handlers(app: FastAPI) -> None:
    app.add_exception_handler(AppError, app_error_handler)
# app/orders/service.py
from app.errors import NotOrderOwnerError, OrderNotFoundError

_orders = {
    1: {"id": 1, "customer_id": "cust-1", "total": 42.0},
}


def get_order_for_customer(order_id: int, customer_id: str) -> dict:
    order = _orders.get(order_id)
    if order is None:
        raise OrderNotFoundError(order_id)
    if order["customer_id"] != customer_id:
        raise NotOrderOwnerError(order_id, customer_id)
    return order
# app/orders/router.py
from fastapi import APIRouter, Header

from app.orders.service import get_order_for_customer

router = APIRouter(prefix="/orders", tags=["orders"])


@router.get("/{order_id}")
def get_order(order_id: int, x_customer_id: str = Header(...)) -> dict:
    return get_order_for_customer(order_id, x_customer_id)
# app/main.py
from fastapi import FastAPI

from app.errors import register_error_handlers
from app.orders.router import router as orders_router

app = FastAPI(title="Exception hierarchy example")
register_error_handlers(app)
app.include_router(orders_router)
# requirements.txt
fastapi==0.115.0
uvicorn[standard]==0.30.6
pydantic==2.9.2
httpx==0.27.2
pytest==8.3.3
# tests/test_errors.py
from fastapi.testclient import TestClient

from app.main import app

client = TestClient(app)


def test_order_not_found_returns_404_with_typed_error():
    response = client.get("/orders/999", headers={"x-customer-id": "cust-1"})
    assert response.status_code == 404
    body = response.json()
    assert body["success"] is False
    assert body["error"]["type"] == "OrderNotFoundError"


def test_wrong_customer_returns_403():
    response = client.get("/orders/1", headers={"x-customer-id": "cust-2"})
    assert response.status_code == 403
    assert response.json()["error"]["type"] == "NotOrderOwnerError"


def test_owner_gets_the_order():
    response = client.get("/orders/1", headers={"x-customer-id": "cust-1"})
    assert response.status_code == 200
    assert response.json()["id"] == 1

Verifying it

python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pytest tests/ -v

Correct output:

tests/test_errors.py::test_order_not_found_returns_404_with_typed_error PASSED
tests/test_errors.py::test_wrong_customer_returns_403 PASSED
tests/test_errors.py::test_owner_gets_the_order PASSED

Both error cases return distinct, consistently-shaped bodies, and neither orders/service.py nor orders/router.py contains a status code or a JSONResponse anywhere — that decision lives entirely in errors.py.

Conclusion

A domain exception should not know it will eventually become an HTTP response. Keeping AppError free of status codes is what lets the same service function be called from a route, a background task, or a test, and mean the same thing in all three.

Structured context and the client-facing message are different things, and conflating them either loses debugging information or leaks it. Keep both on the exception, and decide field by field, in the single handler, what a client is allowed to see.

A category-to-status mapping in one dictionary scales better than a status code at every raise site. New errors within an existing category require no handler change at all; new categories require exactly one new line, in exactly one file, rather than an audit of every route that might need to raise the new kind of error.