In this post
What this post covers
Out of the box, FastAPI answers failures in three different shapes: {"detail": "..."}
for HTTPException, {"detail": [...]} for validation errors, and a plain-text
Internal Server Error for crashes. Clients then need three parsers. Mature APIs define one
error contract, usually RFC 9457 “Problem Details”, and implement it with exception
handlers so that every failure, from a typo’d URL to a database outage, looks the same and never
leaks a stack trace.
A hospital's incident form
Whether the problem is a sprained ankle or a power cut, the hospital fills in the same form: what happened, how serious, a reference number, what to do next. Internal notes (the doctor's full diagnosis, the electrician's report) stay in the internal file, and the reference number links the two.
1. The default shapes
three failures, three different bodies
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/orders/{order_id}")
def get_order(order_id: int):
if order_id == 13:
raise HTTPException(status_code=404, detail="order 13 not found")
if order_id == 500:
raise RuntimeError("connection to db-primary.internal:5432 refused") # a bug / an outage
return {"order_id": order_id}
client = TestClient(app, raise_server_exceptions=False) # behave like a real server on crashes
for url in ["/orders/13", "/orders/abc", "/orders/500", "/nowhere"]:
r = client.get(url)
print(f"{url:12} {r.status_code} {r.headers['content-type']:32} {r.text[:80]}")
Output
/orders/13 404 application/json {"detail":"order 13 not found"}
/orders/abc 422 application/json {"detail":[{"type":"int_parsing","loc":["path","order_id"],"msg":"Input should b
/orders/500 500 text/plain; charset=utf-8 Internal Server Error
/nowhere 404 application/json {"detail":"Not Found"}
TestClient re-raises unhandled exceptions by default, which is handy for debugging tests. Pass
raise_server_exceptions=False to see what a real client would receive.
2. One contract: RFC 9457 Problem Details
| Field | Meaning | Example |
|---|---|---|
type | a stable URI identifying the problem kind: what clients branch on | https://errors.example.com/out-of-stock |
title | a short, stable, human summary of the type | "Out of stock" |
status | the HTTP status code, repeated for convenience | 409 |
detail | this occurrence, for humans | "only 2 of TEA-01 left" |
instance | which request failed | "/v1/orders" |
| extensions | anything else useful: errors (per-field), request_id | … |
The media type is application/problem+json. Here is a complete implementation: domain errors, HTTP errors (including router 404/405), validation errors and crashes all produce the same shape:
exception handlers that implement Problem Details everywhere
import logging
import uuid
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
from starlette.exceptions import HTTPException as StarletteHTTPException
log = logging.getLogger("api")
server_log = [] # stand-in for your log pipeline (lesson 21)
log.addHandler(type("Capture", (logging.Handler,), {"emit": lambda self, rec: server_log.append(rec)})())
log.propagate = False
ERRORS = "https://errors.example.com/"
class DomainError(Exception): # business errors: raised by services
status, slug, title = 400, "domain-error", "Request cannot be completed"
class OutOfStock(DomainError):
status, slug, title = 409, "out-of-stock", "Out of stock"
def problem(request: Request, status: int, slug: str, title: str, detail: str | None, **extra):
body = {"type": ERRORS + slug, "title": title, "status": status, "detail": detail,
"instance": request.url.path, "request_id": request.state.request_id, **extra}
return JSONResponse(body, status_code=status, media_type="application/problem+json")
app = FastAPI()
@app.middleware("http")
async def request_id(request: Request, call_next): # lesson 12 does this properly
request.state.request_id = request.headers.get("x-request-id", uuid.uuid4().hex[:12])
return await call_next(request)
@app.exception_handler(DomainError)
async def on_domain(request: Request, exc: DomainError):
return problem(request, exc.status, exc.slug, exc.title, str(exc))
@app.exception_handler(StarletteHTTPException) # Starlette's base class: also catches router 404/405
async def on_http(request: Request, exc: StarletteHTTPException):
response = problem(request, exc.status_code, f"http-{exc.status_code}", str(exc.detail), None)
response.headers.update(exc.headers or {}) # keep WWW-Authenticate, Retry-After, …
return response
@app.exception_handler(RequestValidationError)
async def on_validation(request: Request, exc: RequestValidationError):
errors = [{"field": ".".join(map(str, e["loc"][1:])), "in": e["loc"][0], "message": e["msg"]}
for e in exc.errors()]
return problem(request, 422, "validation", "Request is invalid", f"{len(errors)} field(s) failed validation",
errors=errors)
@app.exception_handler(Exception) # the last line of defence
async def on_crash(request: Request, exc: Exception):
log.exception("unhandled error, request_id=%s", request.state.request_id) # full detail stays in logs
return problem(request, 500, "internal", "Internal error",
"Something went wrong on our side. Quote the request_id if you contact support.")
class OrderIn(BaseModel):
sku: str
qty: int = Field(gt=0)
@app.post("/orders")
def create(order: OrderIn):
if order.sku == "TEA-01" and order.qty > 2:
raise OutOfStock("only 2 of TEA-01 left")
if order.sku == "BOOM":
raise RuntimeError("password=hunter2 in a connection string") # must never reach clients
return {"ok": True}
@app.get("/admin")
def admin():
raise HTTPException(401, "missing token", headers={"WWW-Authenticate": "Bearer"})
client = TestClient(app, raise_server_exceptions=False)
cases = [("post", "/orders", {"sku": "TEA-01", "qty": 5}), ("post", "/orders", {"sku": "X", "qty": 0}),
("post", "/orders", {"sku": "BOOM", "qty": 1}), ("get", "/nowhere", None),
("delete", "/orders", None), ("get", "/admin", None)]
for method, url, body in cases:
r = client.request(method, url, json=body, headers={"X-Request-ID": "req-7f3a"})
p = r.json()
extra = f" errors={p['errors']}" if "errors" in p else ""
auth = f" WWW-Authenticate={r.headers.get('www-authenticate')}" if r.status_code == 401 else ""
print(f"{r.status_code} {p['type'].removeprefix(ERRORS):13} {p['title']!r:30} {p['detail']!r}{extra}{auth}")
rec = server_log[-1] # the details went to the logs, not to the client
print("server log:", rec.levelname, rec.getMessage(), "|", f"{rec.exc_info[0].__name__}: {rec.exc_info[1]}")
Output
409 out-of-stock 'Out of stock' 'only 2 of TEA-01 left'
422 validation 'Request is invalid' '1 field(s) failed validation' errors=[{'field': 'qty', 'in': 'body', 'message': 'Input should be greater than 0'}]
500 internal 'Internal error' 'Something went wrong on our side. Quote the request_id if you contact support.'
404 http-404 'Not Found' None
405 http-405 'Method Not Allowed' None
401 http-401 'missing token' None WWW-Authenticate=Bearer
server log: ERROR unhandled error, request_id=req-7f3a | RuntimeError: password=hunter2 in a connection string
fastapi.HTTPException is a subclass of starlette.exceptions.HTTPException. The router
raises the Starlette one for unknown paths (404) and wrong methods (405). A handler registered only for
FastAPI's class misses those, and your "consistent" API still returns the default shape for typos.
3. Error-handling rules that hold up
| Rule | Why |
|---|---|
Never return 200 with {"error": ...} | proxies, retries, monitoring and SDKs all branch on the status code |
Stable type values; free-text detail | clients branch on type; you can reword detail without breaking them |
| 500s never include the exception message | messages contain hostnames, SQL, sometimes secrets. Log them with a request id and return the id. |
Services raise domain exceptions, not HTTPException | the service stays usable outside HTTP; mapping lives in one place (part 07) |
| Keep protocol headers | WWW-Authenticate on 401, Retry-After on 429 and 503, Allow on 405 |
| Document error responses in OpenAPI | responses={409: {"model": Problem}} so SDK generators know the shape (part 20) |
| 5xx means "you may retry"; 4xx means "fix your request" | clients and gateways retry on that distinction. Do not return 500 for bad input. |
Recap
- Default FastAPI errors have three shapes; define one contract instead (RFC 9457,
application/problem+json). - Handlers for domain errors, Starlette's
HTTPException(catches 404/405),RequestValidationError, andException. - 500s: log the full traceback server-side, return a generic message plus a request id.
- Stable
typeURIs and preserved protocol headers make errors machine-actionable.
Get the free RAG production checklist
25 checks to run before real users see your RAG feature: exact-match misses, invented citations, prompt injection through documents, quality that slips after a prompt change. Confirm your email and the PDF comes with the welcome email, plus each new experiment.