0009. Return RFC 9457 Problem Details for every error¶
Status: Accepted Date: 2026-08-28
Context¶
M0's walking skeleton needs an error shape before it has more than one route, because retrofitting a consistent error contract onto an API that already has clients is far more disruptive than choosing one at the start.
Decision¶
We return application/problem+json per RFC 9457 for every error the
service produces, with no exception. api/errors.py registers one
handler per error family — domain errors, an unavailable payment
provider, unavailable storage, the framework's own routing and
body-reading failures, and request validation — and every one of them is
rendered through the same ProblemDetail model, at the same media type.
Validation failures are included, not exempted: RequestValidationError,
the exception FastAPI raises for a request body that fails its schema, is
caught and translated into a ProblemDetail rather than left to
FastAPI's default {"detail": [...]} shape.
Alternatives considered¶
- FastAPI's default
{"detail": …}shape. Rejected: it is undocumented as a contract — nothing pins its structure — and it genuinely varies in shape between a validation error, whosedetailis a list of per-field error objects, and a raisedHTTPException, whosedetailis a single string. A client written against one shape breaks silently against the other. - A bespoke error envelope. Rejected: RFC 9457 is a registered IETF standard with existing client and tooling support. Inventing a local shape solves nothing a standard does not already solve, and costs every future integrator a lookup into a format nobody else uses.
Consequences¶
Clients can rely on exactly one error shape across every status code the
service returns, and type gives each error kind a stable identifier —
.../order_not_found, .../validation_error — that survives a later
rewording of the human-readable title or detail.
The cost is a translation layer sitting over FastAPI's own exception
handling: seven handlers in api/errors.py, each responsible for turning
one exception family into the same shape, including a second handler for
a raw pydantic.ValidationError that reaches the boundary from below
rather than from a request body. The layer also enforces a discipline
that has to be maintained deliberately: the domain layer never
constructs an HTTP status code, decides one status per domain error only
in status_for, and the day a domain error needs a new status is a day
that function changes, not a day a domain module reaches for fastapi.
Full reasoning: spec section 4.2.