Skip to content
routebase
API DesignError HandlingREST

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 to about:blank, which means "the status code is all the semantics you get."
  • title — a short human-readable summary of the type. Same type, same title, 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 clearer detail beats 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-Type is application/problem+json, so consumers and middleboxes can recognize an error body without sniffing it.
  • The body's status always matches the response's status line.
  • Unhandled exceptions map to a generic 500 problem — 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 instance is for: an opaque trace ID consumers can quote back to you.
  • Existence hints — a 403 that says "you can't access project X" on a resource the caller shouldn't know exists should be a 404. 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.

Be first to ship on it

Routebase is launching soon. Join the waitlist and we'll email you the moment it's ready.

One email at launch. No spam, unsubscribe anytime.