Consistent Error Handling with RFC 9457 Problem Details
· The Routebase Team

Ask consumers of any mature API where the integration pain lives, and the
answer is rarely the happy path. It's the errors: one endpoint returns
{"error": "invalid input"}, the next returns {"message": "..."} with a
code field, a third lets the framework's default HTML error page leak
through. Every consumer ends up writing — and maintaining — a small parser
for each variant.
This is a solved problem. RFC 9457 Problem Details defines a standard shape for HTTP error responses, and it's small enough to adopt in an afternoon. The hard part, as with most API design, isn't the format — it's applying it everywhere. This post covers both.
The shape: five fields and a media type
A Problem Details response is a JSON object served with the media type
application/problem+json. Five members are defined:
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/plan-limit-reached",
"title": "Plan limit reached",
"status": 403,
"detail": "Your Team plan allows 10 projects; this org has 10.",
"instance": "/orgs/acme/projects"
}type— a URI that identifies the kind of error. This is the field consumers branch on. If omitted, it defaults toabout:blank, which means "the status code is all the semantics you get."title— a short human-readable summary of the type. Sametype, sametitle, every time.status— the HTTP status code, duplicated into the body so the error object stays meaningful when it's logged, queued, or wrapped.detail— a human-readable explanation of this occurrence. This is the field that varies per response.instance— a URI reference identifying the specific occurrence, typically the request path or a trace lookup URL.
All five are optional in the RFC. In practice, treat type, title,
status, and detail as required in your API — an error without a type
forces consumers back to string-matching prose, which is the exact failure
mode the format exists to prevent.
type is a contract, not a link
The type URI does not need to resolve to anything — consumers must treat
it as an opaque, stable identifier. But making it resolve to your error
documentation is a cheap gift to every developer who hits it in a log at
2am.
Two rules keep type trustworthy:
- Stable forever. Renaming an error type is a breaking change, exactly
like renaming a response field. Consumers write
if (problem.type.endsWith("/plan-limit-reached"))— that string is now part of your contract. - One type per distinguishable failure. If a consumer would plausibly
handle two failures differently — retry one, surface the other — they
need different
types. If not, one type with a clearerdetailbeats an inflated error catalog.
Keep a registry of your error types next to your spec, and treat additions the way you treat new endpoints: reviewed, documented, deliberate.
Extensions: where your domain fits
Problem Details objects are open — any additional member is an "extension." This is where the format earns its keep, because it gives structured, per-error data a defined home instead of a prose sentence. The canonical example is validation:
{
"type": "https://api.example.com/errors/validation",
"title": "Validation failed",
"status": 422,
"detail": "2 fields failed validation.",
"errors": [
{ "field": "email", "message": "must be a valid email address" },
{ "field": "plan", "message": "must be one of: free, team, business" }
]
}A consumer maps errors[] straight onto form fields — no regex over
detail, no guessing. The same pattern carries any machine-actionable
context: a retryAfter on rate limits, a requiredScope on authorization
failures, a conflictingResource on 409s. The rule of thumb: if a
consumer could act on a piece of information, it belongs in a named
extension member, not in the prose.
Extensions are also your evolution path: adding a member is additive and safe, so the error schema can grow without breaking consumers.
One shape means one code path
The value of Problem Details is multiplicative: it pays off only if every
error, on every endpoint, uses it. One endpoint that returns
{"error": "..."} and consumers are back to writing per-endpoint parsers —
now with one more variant than before.
That consistency is not a discipline problem; it's an architecture
decision. Errors should be produced in exactly one place — an exception
mapper, middleware, or error handler at the edge of your stack — never
hand-assembled inside endpoint code. Most frameworks have this built in
(ASP.NET Core's ProblemDetails support, Spring's ProblemDetail,
libraries for Express and Go). The checklist:
- Every non-2xx response body is a Problem Details object — including
404s from unmatched routes,405s, and auth failures that fire before your handlers run. - The
Content-Typeisapplication/problem+json, so consumers and middleboxes can recognize an error body without sniffing it. - The body's
statusalways matches the response's status line. - Unhandled exceptions map to a generic
500problem — they must not bypass the mapper and leak a framework error page.
The framework defaults get you to "handled exceptions look right." The last two bullets are where audits usually find the gaps.
What stays out of the error body
Errors are a favorite reconnaissance surface, and detail is where
well-meaning debugging information goes to become an information leak.
Keep out of every error response:
- Stack traces and exception class names — they document your stack for attackers and nobody else.
- Internal identifiers — database keys, hostnames, queue names.
If support needs a correlation handle, that's what
instanceis for: an opaque trace ID consumers can quote back to you. - Existence hints — a
403that says "you can't access project X" on a resource the caller shouldn't know exists should be a404. Decide the policy per resource, then encode it in the error mapper, not in each handler's judgment.
The test for detail is simple: it should help the caller fix their
request, not understand your implementation.
Agents raise the stakes
Everything above predates AI, but AI collapsed the tolerance for ignoring
it. A human meeting an unstructured error reads your docs; an
AI agent does whatever the error body
suggests — and an error body that suggests nothing produces retry loops,
hallucinated explanations, or an agent that silently routes around your
API. A Problem Details response with a stable type and an actionable
detail is, for an agent, a recovery instruction. "Forbidden" is a dead
end.
Make it part of the contract
An error format that lives in a wiki page is a suggestion; one that lives
in the spec is a contract. Define your problem object once as a shared
schema, reference it from every non-2xx response in your OpenAPI spec, and
document each type where the endpoints are documented. Then the format
is visible in review, testable against real responses, and impossible to
drift from silently — which is the design-first
workflow applied to the least-designed
surface most APIs have. That's the workflow
Routebase is built around — errors included.