Files
2026-06-01 21:42:05 +00:00

4.5 KiB

Errors

Use handlers and dependencies to signal HTTP meaning clearly, and let FastAPI or Starlette own the wire format.

Why this convention exists

FastAPI sits at a boundary where several kinds of failures meet:

  • invalid requests
  • permission failures
  • missing resources
  • domain or infrastructure errors
  • framework/runtime exceptions

Good code keeps those concerns separate.

The stable pattern is:

  • handlers and dependencies raise semantic HTTP exceptions for boundary failures
  • request validation errors come from FastAPI and Pydantic automatically
  • exception handlers turn those failures into HTTP responses
  • tests assert the HTTP contract, not private exception plumbing

That keeps route code small and error responses consistent.

The convention

  1. Raise HTTPException for request-level failures such as 400, 401, 403, and 404.
  2. Let FastAPI and Pydantic produce validation errors instead of hand-rolling error dicts.
  3. Centralize error-envelope formatting in exception handlers when the API needs a stable shape.
  4. Keep internal exception details out of client responses unless that disclosure is part of the contract.
  5. Keep domain exceptions framework-agnostic and translate them at the HTTP boundary.

Follow it at the HTTP boundary

Use this whenever the failure is about what the caller sent, what the caller can access, or what the API contract should look like on failure.

Avoid these mistakes

Do not raise HTTPException from deep reusable domain code.

Do not build ad hoc error JSON in each route.

Do not catch every exception just to expose str(exc) to clients.

Preferred shape

@router.get("/{id}")
def read_item(session: SessionDep, current_user: CurrentUser, id: UUID) -> ItemPublic:
    item = session.get(Item, id)
    if not item:
        raise HTTPException(status_code=404, detail="Item not found")
    if item.owner_id != current_user.id:
        raise HTTPException(status_code=403, detail="Not enough permissions")
    return item

Why this works:

  • the failure is explicit at the boundary
  • the status code carries the meaning
  • the framework serializes it consistently

Counterexamples

Returning error dicts from handlers

return {"error": "not_found", "message": "Item not found"}

Bad because success and failure flows blur together and status handling becomes inconsistent.

Framework exceptions in core domain code

class BillingService:
    def charge(...):
        raise HTTPException(status_code=402, detail="Payment required")

Bad because domain code is now coupled to one web framework.

Catch-all 500 handling that leaks internals

except Exception as exc:
    raise HTTPException(status_code=500, detail=str(exc))

Bad because it leaks internal details and destroys useful operational distinctions.

Source signals

FastAPI

  • fastapi/exception_handlers.py:11-26 is the core citation here: HTTPException becomes a JSON {"detail": ...} response when a body is allowed, and RequestValidationError becomes a 422 response using exc.errors().
  • docs_src/bigger_applications/app_an_py310/routers/items.py:21-25 raises HTTPException(status_code=404, detail="Item not found") for a missing resource.
  • docs_src/bigger_applications/app_an_py310/routers/items.py:33-37 raises HTTPException(status_code=403, detail=...) for a forbidden update.

Full-stack FastAPI Template

  • backend/app/api/deps.py:30-45 translates invalid token, missing user, and inactive user states into semantic HTTP errors inside a dependency.
  • backend/app/api/routes/items.py:53-57 uses 404 and 403 in the read handler.
  • backend/app/api/routes/items.py:86-95 repeats the same boundary pattern on update before applying the mutation.
  • backend/app/api/routes/items.py:106-112 repeats it on delete before committing the write.

Pydantic and Starlette

  • pydantic/docs/index.md:109-152 shows invalid input producing structured validation errors rather than ad hoc strings.
  • starlette/tests/test_applications.py:218-238 asserts 404, custom 405, and 500 responses at the HTTP layer, reinforcing that response behavior is a framework contract, not just an implementation detail.
  • starlette/applications.py:35-48 separates middleware and exception handlers in application setup, which is part of why centralized formatting works cleanly.

Bottom line

Routes and dependencies should express HTTP meaning.

Exception handlers should own HTTP formatting.

Core logic should stay framework-agnostic until the boundary translates it.