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
- Raise
HTTPExceptionfor request-level failures such as 400, 401, 403, and 404. - Let FastAPI and Pydantic produce validation errors instead of hand-rolling error dicts.
- Centralize error-envelope formatting in exception handlers when the API needs a stable shape.
- Keep internal exception details out of client responses unless that disclosure is part of the contract.
- 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-26is the core citation here:HTTPExceptionbecomes a JSON{"detail": ...}response when a body is allowed, andRequestValidationErrorbecomes a 422 response usingexc.errors().docs_src/bigger_applications/app_an_py310/routers/items.py:21-25raisesHTTPException(status_code=404, detail="Item not found")for a missing resource.docs_src/bigger_applications/app_an_py310/routers/items.py:33-37raisesHTTPException(status_code=403, detail=...)for a forbidden update.
Full-stack FastAPI Template
backend/app/api/deps.py:30-45translates invalid token, missing user, and inactive user states into semantic HTTP errors inside a dependency.backend/app/api/routes/items.py:53-57uses 404 and 403 in the read handler.backend/app/api/routes/items.py:86-95repeats the same boundary pattern on update before applying the mutation.backend/app/api/routes/items.py:106-112repeats it on delete before committing the write.
Pydantic and Starlette
pydantic/docs/index.md:109-152shows invalid input producing structured validation errors rather than ad hoc strings.starlette/tests/test_applications.py:218-238asserts 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-48separates 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.