Text size
Dark mode
RecallRunGuides & benchmarks
Learn to build real systemsGuides•Benchmarks•Experiments•Community
Search the site/Free checklist

Guides/FastAPI

FastAPI: Errors & a consistent error contract

Out of the box, FastAPI answers failures in three different shapes : {'detail': '...'} for HTTPException , {'detail': [...]} for validation errors, and a plain-text…

In this post

What this post covers
  1. 1. The default shapes
  2. 2. One contract: RFC 9457 Problem Details
  3. 3. Error-handling rules that hold up
  4. Recap

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.

raise HTTPException / ApiErrora decision your code made invalid inputRequestValidationError a bug or an outageKeyError, timeout, driver error… ExceptionMiddlewarefinds a handler by exceptionclass (and its parents) ServerErrorMiddlewareoutermost; no handler matched 4xx problem+jsonstable type, detail,request id for support 500, generic bodytraceback only in the logs

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"}
raise_server_exceptions

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

FieldMeaningExample
typea stable URI identifying the problem kind: what clients branch onhttps://errors.example.com/out-of-stock
titlea short, stable, human summary of the type"Out of stock"
statusthe HTTP status code, repeated for convenience409
detailthis occurrence, for humans"only 2 of TEA-01 left"
instancewhich request failed"/v1/orders"
extensionsanything 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
Register the Starlette HTTPException

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

RuleWhy
Never return 200 with {"error": ...}proxies, retries, monitoring and SDKs all branch on the status code
Stable type values; free-text detailclients branch on type; you can reword detail without breaking them
500s never include the exception messagemessages contain hostnames, SQL, sometimes secrets. Log them with a request id and return the id.
Services raise domain exceptions, not HTTPExceptionthe service stays usable outside HTTP; mapping lives in one place (part 07)
Keep protocol headersWWW-Authenticate on 401, Retry-After on 429 and 503, Allow on 405
Document error responses in OpenAPIresponses={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, and Exception.
  • 500s: log the full traceback server-side, return a generic message plus a request id.
  • Stable type URIs 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.

One email to confirm. Unsubscribe any time. What's in it · Privacy

Want the production version of this? See ShipRAG: A production RAG + MCP starter kit for FastAPI: hybrid search, validated citations, evals in CI, Docker, 32 tests.

Written by

Alby Tomy

Python/FastAPI Engineer | Data Engineer | AI Engineer building RAG and MCP systems.

X · GitHub · Contact & enquiries