Typed error contracts for FastAPI — from Python exceptions to RFC 9457 and OpenAPI.
fastapi-faults gives every application error one immutable definition and
uses it everywhere: exception handling, application/problem+json responses,
and generated OpenAPI documentation.
domain exception ──▶ Fault ──▶ runtime response
└──▶ OpenAPI schema
No duplicated responses={...} dictionaries, no process-global registry, and
no drift between what an endpoint documents and what it actually returns.
- One source of truth — status, stable code, title, detail, headers, examples,
and schemas live in one
Fault. - Real Problem Details — errors use the RFC 9457 media type and structure.
- OpenAPI that stays honest —
responses=registry.responses(...)produces the matching response documentation automatically. - Typed extension members — Pydantic models validate custom problem fields and generate their schemas.
- Feature-local design — define faults beside a feature, then compose registries at the application boundary.
- Safe defaults — request validation, FastAPI HTTP errors, and unexpected failures can be normalized without exposing private inputs or internals.
- Contract testing included — assert response shape, OpenAPI coverage, and undeclared runtime faults.
Install the package from PyPI:
pip install fastapi_faultsDefine a domain exception, map it once, and declare it on the route that can raise it:
from fastapi import APIRouter, FastAPI
from fastapi_faults import Fault, FaultRegistry
class SessionNotFound(Exception):
def __init__(self, session_id: str) -> None:
self.session_id = session_id
SESSION_NOT_FOUND = Fault(
SessionNotFound,
status=404,
code="session_not_found",
title="Session not found",
detail=lambda error: f"Session {error.session_id} does not exist.",
)
session_faults = FaultRegistry(
name="sessions",
faults=[SESSION_NOT_FOUND],
)
router = APIRouter(prefix="/sessions", tags=["sessions"])
@router.get(
"/{session_id}",
responses=session_faults.responses(SESSION_NOT_FOUND),
)
async def get_session(session_id: str) -> dict[str, str]:
raise SessionNotFound(session_id)
app = FastAPI()
app.include_router(router)
api_faults = FaultRegistry.merge(
session_faults,
name="api",
type_base="https://api.example.com/problems",
)
api_faults.install(app)A request to GET /sessions/abc now returns:
HTTP/1.1 404 Not Found
content-type: application/problem+json{
"type": "https://api.example.com/problems/session_not_found",
"title": "Session not found",
"status": 404,
"code": "session_not_found",
"detail": "Session abc does not exist."
}The same route is documented in OpenAPI with a 404 response using
application/problem+json and a reusable SessionNotFoundProblem schema.
Each feature owns a small registry. The application composes them explicitly:
api_faults = FaultRegistry.merge(
browser_faults,
session_faults,
account_faults,
name="api",
type_base="https://api.example.com/problems",
)Definitions are immutable, merge order is deterministic, and conflicting exception classes, codes, type URIs, or schema names fail during configuration.
type_base derives a stable problem type URI from each fault's code. A
fault can instead provide an explicit type. Installation fails when a domain
fault has neither, keeping incomplete contracts out of a running application.
RFC 9457 allows problem-specific extension members at the top level. Use a Pydantic model to validate those values and describe them in OpenAPI:
from pydantic import BaseModel
class ConflictFields(BaseModel):
current_version: int
SESSION_CONFLICT = Fault(
SessionConflict,
status=409,
code="session_conflict",
title="Session conflict",
extensions_model=ConflictFields,
extensions=lambda error: ConflictFields(current_version=error.version),
)This produces a top-level current_version member at runtime and an integer
property in the generated problem schema.
Use FastAPI's standard APIRouter and generate its responses metadata from
the feature registry:
@router.get(
"/{session_id}",
responses=session_faults.responses(SESSION_NOT_FOUND),
)
async def get_session(session_id: str) -> SessionView:
...This is the canonical route API. fastapi-faults does not subclass or replace
APIRouter. Every declared fault must belong to the registry installed on the
application.
Installing a registry normalizes FastAPI and Starlette failures by default:
| Failure | Default behavior |
|---|---|
| Request validation | 422 Problem Details with stable, location-aware errors |
HTTPException / routing errors |
Matching Problem Details response |
| Response validation | Safe internal-error response |
| Unexpected exception | Safe internal-error response |
Built-in handlers can be selected at installation time:
api_faults.install(
app,
include_validation_error=True,
include_http_exceptions=True,
include_unhandled_error=True,
)The repository's own test helpers live in tests/helpers.py and are not shipped
with the library. Within this checkout, they can check runtime and documentation
drift:
from tests.helpers import (
assert_no_undeclared_faults,
assert_openapi_contract,
assert_problem,
)
assert_openapi_contract(app)
response = client.get("/sessions/abc")
problem = assert_problem(
response,
SESSION_NOT_FOUND,
type_uri="https://api.example.com/problems/session_not_found",
)
assert problem["detail"] == "Session abc does not exist."
async with assert_no_undeclared_faults(app):
# Exercise routes here. The context fails afterward if a registered fault
# occurred without being declared in that operation's responses metadata.
...The undeclared-fault monitor must be entered before the application's first request.
- CPython 3.12, 3.13, or 3.14
- FastAPI 0.115 or newer (below 1.0)
- Pydantic 2.9 or newer (below 3.0)
Clone the repository, then install all dependency groups:
uv sync --all-groupsRun the same quality gates used by the project:
uv run ruff format --check .
uv run ruff check .
uv run mypy
uv run pytest
uv buildRunnable samples live in examples/minimal and the
feature-oriented examples/namespaced showcase.
Released under the MIT License.