Errors¶
Every error response uses RFC 9457 Problem Details, the internet standard shape for a JSON error body. A client can then read errors from any service the same way, instead of learning a bespoke error format per service.
The content type is application/problem+json, not application/json.
The shape¶
{
"type": "https://errors.example.com/order_not_found",
"title": "Order not found",
"status": 404,
"detail": "no order with id 3fa85f64-5717-4562-b3fc-2c963f66afa6",
"instance": "/api/v1/orders/3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
| Field | Meaning |
|---|---|
type |
A stable identifier for the kind of error. This is what code should branch on. |
title |
A short human-readable summary of the kind. Does not change between occurrences. |
status |
The HTTP status code, repeated in the body so a logged body is self-contained. |
detail |
What went wrong this time. Omitted when there is nothing to add. |
instance |
The path of the request that failed. |
Match on type, never on title or detail. title is prose and may be
reworded; detail varies per occurrence.
type is a URL by convention, not a promise that anything is served there.
The service uses https://errors.example.com/<code>, which is a
placeholder — a real service should point it at documentation it actually
publishes.
Errors the service returns¶
| Status | type suffix |
When |
|---|---|---|
| 400 | http_error |
The request could not be read at all — for example, a body that is not valid UTF-8. Raised by the framework itself, before routing gets a chance to run. |
| 402 | payment_declined |
The payment provider declined the card. |
| 404 | order_not_found |
No order has that id. |
| 404 | http_error |
No route matches this path. Same status as the row above, different type — see the warning below. |
| 405 | http_error |
The path exists, but not for this HTTP method. Carries an Allow header naming the methods that do work. |
| 422 | validation_error |
The request body broke a rule. |
| 500 | internal_error |
An unhandled failure. |
| 503 | payment_unavailable |
The payment provider could not be reached, or its circuit breaker is open. Carries a Retry-After header set from the configured breaker cool-down. |
| 503 | storage_unavailable |
The receipt store could not be reached. Carries a Retry-After header of a fixed 30 seconds — there is no circuit breaker in front of object storage to read a cool-down from. |
422 is used for a request that is well-formed JSON but breaks a rule —
a quantity of zero, a currency of eur, two lines in different currencies.
402 is specific to POST /api/v1/orders, the only route that talks to a
payment provider — see HTTP API and
Outbound HTTP calls. 503 comes from two
routes with two types: payment_unavailable from that same POST, and
storage_unavailable from GET /api/v1/orders/{order_id}/receipt
(HTTP API).
The 400, 404 http_error and 405 rows are not raised by this service's own
route handlers at all — they come from the framework, before a route handler
ever runs: an unreadable body, an unmatched path, or a path matched to the
wrong method. All three share the one type suffix, http_error, because
they are the same kind of fact — "the framework could not even dispatch
this request" — reported at three different statuses.
Two different 404s, one status, two types
order_not_found and this http_error row both answer 404, and that is
not a coincidence to resolve — they are genuinely different facts that
happen to share a status code. order_not_found means "the path was
valid and led to a real route, but no order has that id." http_error
means "no route matches this path at all." A client that branches on
type, per the rule stated above ("Match on type, never on title
or detail"), sees the two correctly as unrelated. A client that
branches on status alone — which that rule does not cover, because
until now nothing on this page needed two types behind one status —
cannot tell them apart. Treat status the same way: never the sole
thing you branch on.
A 500 tells you nothing about the internals¶
{
"type": "https://errors.example.com/internal_error",
"title": "Internal server error",
"status": 500,
"instance": "/api/v1/orders"
}
There is no detail. The full exception and traceback go to the log as one
structured record, keyed by the same correlation identifier the response
carries in X-Request-ID. So give that header value to whoever reports the
problem: it is enough to find the exact traceback, and it reveals nothing to
the client.
How a status code is chosen¶
The domain layer never mentions HTTP. It raises errors that say which business rule broke:
class DomainError(Exception):
code: str = "domain_error"
title: str = "Domain rule violated"
class OrderNotFoundError(DomainError):
code = "order_not_found"
title = "Order not found"
One module in the api layer maps those onto status codes. Deciding that "not found" means 404 is a statement about a transport protocol, so it lives with the transport. The same domain error would map to a different code in a message-queue consumer, and the domain does not have to care.
A domain error with no explicit mapping becomes 422.
The mapping walks the class hierarchy rather than looking up the exact class,
so a subclass inherits its parent's status. A future
OrderAlreadyShippedError subclassing OrderNotFoundError answers 404,
rather than silently falling through to the 422 default.
Known rough edge: detail on a 422¶
The detail field of a validation error currently contains the Python
representation of the validator's error list:
{
"detail": "[{'type': 'greater_than', 'loc': ('body', 'lines', 0, 'quantity'), 'msg': 'Input should be greater than 0', 'input': 0, 'ctx': {'gt': 0}}]"
}
Note the single quotes and the tuple: that is Python syntax, not JSON. It is
readable by a person but awkward for a client to parse, and it is recorded
here as a known limitation rather than presented as a designed format. Treat
type and status as the contract; treat detail on a 422 as a diagnostic
for humans.
A deliberate mislabel, recorded on purpose¶
One handler catches every validation error raised anywhere in the application, wherever it comes from, and reports 422.
That is right for the case it exists for: input that passed the edge schema and was rejected by a deeper validator. It is wrong for a validation error with no connection to client input at all — a service constructing a domain object from a value it computed itself, incorrectly. That is a server defect and deserves a 500, but nothing in the exception distinguishes the two, so it is currently reported as a client fault.
The real fix is translating validation at the service-layer boundary, so a
service's own bugs surface as 500s and only genuinely bad client input reaches
the handler. The services here are too thin for that boundary to pay for
itself, so it is not built yet. Every occurrence is logged at warning level
with the correlation identifier, so the case is loud rather than silent.