Typed, batteries-included RFC 9457 "Problem Details for HTTP APIs" for FastAPI & Pydantic.
Define an error once - it serializes as application/problem+json, documents
itself in OpenAPI, and parses back into a typed exception on the client.
from fastapi import FastAPI
from fastapi_rfc9457 import Problem
from fastapi_rfc9457.server import add_problem_handlers, get_problem_docs_router, problems
class OutOfCredit(Problem):
"""The account does not have enough credit."""
title = "Out of Credit"
status = 403
balance: int # typed extension members, checked at the raise site
accounts: list[str]
class AccountSuspended(Problem):
"""The account is suspended and cannot be charged."""
title = "Account Suspended"
status = 403
app = FastAPI()
add_problem_handlers(app) # handlers + problem+json OpenAPI
app.include_router(get_problem_docs_router(), prefix="/problems") # dereferenceable type URIs
@app.get("/charge", responses=problems(OutOfCredit, AccountSuspended))
async def charge() -> dict:
raise OutOfCredit(detail="Not enough credit.", balance=30, accounts=["/acct/12"])One route can declare several failure modes. Distinct statuses get their own
response; same-status problems become a oneOf union you flip through in
Swagger's Examples dropdown — all under application/problem+json.
Mount the docs router and every problem type resolves to a live page listing
its typed extension members.
The type is derived from the docs-router mount, not hard-coded: mount at
prefix="/problems" and OutOfCredit emits and serves /problems/out-of-credit.
Change the prefix and bodies, OpenAPI, and doc pages move together. Set type
explicitly to emit a literal URI instead.
Client-side, the package can parse
application/problem+json back into typed problems the server raised.
import httpx
from fastapi_rfc9457 import Problem, httpx_raise_hook
class OutOfCredit(Problem): # the type the server declares, shared or re-stated
title = "Out of Credit"
status = 403
balance: int
with httpx.Client(
base_url="http://localhost:8000",
event_hooks={
"response": [httpx_raise_hook()]
}) as client:
try:
client.get("/charge")
except OutOfCredit as exc:
print(exc.balance) # extension members round-trip back as typed attributesPrefer to parse explicitly? parse_problem(response) returns the typed Problem
(or a generic ProblemDetail for an unknown type), and raise_for_problem(response)
raises it.
see Handling Errors
class OutOfCreditError(Exception):
def __init__(self, detail: str, balance: int) -> None:
self.detail, self.balance = detail, balance
@app.exception_handler(OutOfCreditError)
async def _(request: Request, exc: OutOfCreditError) -> JSONResponse:
return JSONResponse({"detail": exc.detail, "balance": exc.balance}, 403)
class OutOfCreditBody(BaseModel):
detail: str
balance: int
@app.get("/charge", responses={403: {"model": OutOfCreditBody}})
async def charge(token: str | None = None) -> dict:
if token is None:
raise HTTPException(401, "Log in first")
raise OutOfCreditError("Not enough credit", balance=30)# fastapi-rfc9457 enables a single class for the exception, the body, and the OpenAPI schema
from fastapi_rfc9457 import NotAuthenticated, Problem # NotAuthenticated ships built in
from fastapi_rfc9457.server import problems
class OutOfCredit(Problem):
title = "Out of Credit"
status = 403
balance: int
@app.get("/charge", responses=problems(NotAuthenticated, OutOfCredit))
async def charge(token: str | None = None) -> dict:
if token is None:
raise NotAuthenticated(detail="Log in first")
raise OutOfCredit(detail="Not enough credit", balance=30)
# → 403 application/problem+json
# {"type": "/problems/out-of-credit", "title": "Out of Credit",
# "status": 403, "detail": "Not enough credit", "balance": 30}| Plain FastAPI | fastapi-rfc9457 | |
|---|---|---|
| Typed extra fields in the body and OpenAPI | exception + handler + model, by hand | ✅ |
Errors documented as application/problem+json |
❌ (application/json) |
✅ |
Same-status errors as oneOf + Examples dropdown |
❌ | ✅ |
Dereferenceable type URIs with doc pages |
❌ | ✅ |
uv add fastapi-rfc9457[server] # FastAPI apps: handlers, OpenAPI, docs router
uv add fastapi-rfc9457 # lean client: author + parse problems, Pydantic onlycd example && uv run uvicorn main:app --reload # then open localhost:8000/docsSee example/ for the full runnable app, and
example/client.py for the httpx hook (uv add fastapi-rfc9457 httpx)
that raises those problems back as typed exceptions on the consumer side.
- Replaces FastAPI's default 422 body with
application/problem+json.

