# Consistent Error Handling with RFC 9457 Problem Details — Routebase

> Why every API needs exactly one error shape, how RFC 9457 Problem Details provides it, and the conventions — stable type URIs, validation extensions, safe detail fields — that make it stick.

Canonical page: https://routebase.dev/blog/consistent-error-handling-rfc-9457/
Published: 2026-07-08 · The Routebase Team · API Design, Error Handling, REST

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](https://www.rfc-editor.org/rfc/rfc9457) 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](/blog/api-design-principles/),
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
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](/api-testing/).
- **One type per distinguishable failure.** If a consumer would plausibly
  *handle* two failures differently — retry one, surface the other — they
  need different `type`s. 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.

_Figure: Consumers branch on the type URI, which makes every type a load-bearing string: three types, three handlers, three working edges. Rename one — plan-limit-reached to plan-limit — and its edge snaps, because the consumer's branch still matches the old string. Renaming an error type breaks consumers exactly like renaming a response field._

## 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:

```json
{
  "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 `409`s. 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](/blog/api-versioning-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.

_Figure: The cost that matters is the marginal one. Without an agreed shape, every endpoint a consumer integrates adds another parser to write and maintain, and the next endpoint you ship adds one more. With one Problem Details shape across the API, the consumer writes a single parser and branches on `type` — and the next endpoint costs nothing at all._

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
  `404`s from unmatched routes, `405`s, 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](/blog/designing-agent-ready-apis/) 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](/blog/design-first-vs-code-first/) applied to the least-designed
surface most APIs have. That's the workflow
[Routebase](/api-design/) is built around — errors included.

---

[Routebase](https://routebase.dev/) — [Sign up](https://app.routebase.dev/): Every account starts with a 14-day Pro trial — no credit card required.
