Errorserrorserror bodyerror coderequest idcontract

The Error Model: Structure Over Apology

A failing response is still a response, and clients write code against it. A stable error model — machine-readable code, human message, request id, structured details — is a contract clause, not a courtesy.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
When this API fails, what exactly does the client learn, and what can its code do about it?
Consumers
Every client's error-handling branch: the retry wrapper deciding whether to try again, the form renderer deciding which field to highlight, the on-call engineer pasting a request id into the provider's support form at 02:00.
The promise
A well-designed error model guarantees that every failure carries a stable machine-readable code the client can branch on, a human message it may display but never parse, and a correlation id that turns "it failed" into a findable event.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

Errors are read by programs first

The instinct behind {"error": "something went wrong"} is honest — the server genuinely does not know what to say — but the consequence is that every client now knows *less* than the server did. The client cannot distinguish "your request was malformed, do not retry" from "we had a hiccup, retry in a second" from "you are over quota until midnight". All three demand different client code, and the contract just collapsed them into one string.

What follows is predictable and worse than the original failure: clients start parsing message text. if (msg.includes("duplicate")) ships in a partner integration, works for a year, and breaks the day a copywriter improves the wording. Now your error message text is load-bearing — the least stable field in the response has become the most depended-upon. This is What an API Contract Actually Is applied to failures: if you do not give clients a designed field to branch on, they will branch on whatever you gave them.

The error model is therefore a schema, designed once and reused by every endpoint. Its stability requirements are *stricter* than the success path's: success responses evolve per resource, but error handling is written once per client and shared across every call site.

  • `code` — stable, machine-readable, documented, enumerable: rate_limited, card_declined, project_not_found. The field clients branch on. Never renamed, only extended.
  • `message` — human-readable, for logs and debugging. Explicitly documented as changeable text that clients must not parse.
  • `details` — structured, code-specific payload: which field failed validation, which limit was hit, when to retry (see Validation Errors: Feedback, Not Verdicts).
  • `request_id` — the correlation handle. Echoed from the request or generated at the edge; the one field support and on-call actually use (see Request IDs: The Contract's Correlation Clause).
  • `doc_url` (optional) — a stable link to the error's documentation; cheap for you, minutes saved for every integrator.

One shape, everywhere — read as an exchange

The HTTP status code and the body code do different jobs, and the model needs both. The status is for generic machinery — proxies, caches, SDK retry layers — which knows nothing about your domain but understands "client error" vs "server error" vs "back off" (see Status Codes Clients Can Branch On). The body code is for domain logic: the same 409 might mean duplicate_email on one endpoint and stale_version on another, and the client handles those very differently.

The exchange below shows the division of labor. A payment fails; the 402 tells the retry wrapper not to bother; card_declined tells the checkout flow which UX path to take; details.decline_reason feeds the message shown to the buyer; request_id ties the whole thing to a trace the provider can find. Four consumers of one error, each served by a designed field.

Each field of the error serves a different reader
Request
POST /v1/charges HTTP/1.1
Authorization: Bearer <token>
Idempotency-Key: 8c41…
Content-Type: application/json

{
  "amount": 4999,
  "currency": "EUR",
  "source": "card_7x2…"
}
Response
HTTP/1.1 402 Payment Required
Request-Id: req_01J8…
Content-Type: application/json

{
  "error": {
    "code": "card_declined",
    "message": "The card was declined.",
    "details": { "decline_reason": "insufficient_funds" },
    "request_id": "req_01J8…",
    "doc_url": "https://api.example.com/docs/errors#card_declined"
  }
}

The shape is easy; the discipline is the product

Designing the envelope takes an afternoon. The work is holding the line afterwards: every endpoint, every framework default, every dependency's exception must be translated into the model before it leaves the process. The moment one endpoint leaks a stack trace, an HTML 500 page from a proxy, or a raw database error, clients need a second error handler — and you need to guess which one they wrote.

The leak is also a security matter: raw errors carry table names, file paths, library versions and occasionally query fragments. The error boundary that translates everything into the model is the same boundary that keeps internals internal. What the client needs is "what happened to *my request* and what can *I* do" — never "what happened inside your process".

Three endpoints, three accidental error dialects
1POST /users500 "Internal Server Error" (HTML from proxy)
2POST /payments400 { "error": "something went wrong" }
3GET /projects/9200 { "success": false,
4 "msg": "SQLSTATE 23505: duplicate key
5 users_email_key" }
6# clients parse messages, leak internals, and
7# cannot tell retryable from permanent
One envelope, translated at the boundary
1ANY endpoint, ANY failure
2HTTP <status>
3{
4 "error": {
5 "code": "duplicate_email", # stable, documented
6 "message": "…may change…", # display only
7 "details": { "field": "email" },
8 "request_id": "req_01J8…"
9 }
10}
11# unknown internal failure? code: "internal",
12# details omitted, full context in server logs under request_id

The left side is not three mistakes — it is one missing decision surfacing three ways. Every unhandled path takes whatever shape the nearest framework layer produces. The envelope plus a translation boundary makes "what does failure look like" a decision made once.

Key points

  • Error responses are parsed by programs; a designed code field is the only alternative to clients parsing message text.
  • Status code and body code do different jobs: status for generic machinery (retry layers, proxies), body code for domain logic.
  • Messages are for humans and must be documented as changeable; the day a client parses one, your copy is load-bearing.
  • A request_id in every error turns support tickets and incident timelines from archaeology into lookup.
  • One envelope across every endpoint and failure path — including the ones your framework handles for you — or clients write N error handlers.
  • Never leak internals: stack traces, SQL state and file paths belong in your logs under the request id, not in the response.

Progressive depth

Overview

An error response is the part of the contract clients write the most code against. It needs a machine-readable code, a human message, and a request_id — a free-text string is not an error model.

Practical

Use status codes for the category (4xx client, 5xx server, 409 conflict, 429 limited) and a stable code registry for the cause (ALREADY_MEMBER, LAST_OWNER). Field-level details[] for validation; Retry-After for anything retryable (An Error Taxonomy Clients Can Branch On, Validation Errors: Feedback, Not Verdicts, Retryability: Telling Clients What To Do Next).

Advanced

Errors evolve too: adding a code is additive, renaming one is breaking, and clients must be told to fall back on the status category for codes they do not know — the same forward-compatibility rule as Enum Evolution: The New Value That Broke Old Clients.

Internals

Map internal exceptions to public codes at one boundary (the API layer), never letting a database constraint name or a stack trace reach the wire; log the internal cause with the request_id so the public code stays stable while the diagnosis stays precise (Request IDs: The Contract's Correlation Clause, API Logging Without Leaking).

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → API: ships success-path JSON carefully and lets failures fall through to framework defaults.
  2. 2
    Clients → API: meet four different error shapes; the pragmatic ones branch on HTTP status plus message substrings.
  3. 3
    Team → copy edit: "improves" an error message; a partner's includes("duplicate") check silently stops matching.
  4. 4
    Partner → production: duplicate signups start creating support tickets instead of inline errors; nobody connects it to the copy change.
  5. 5
    Provider → support: tickets arrive as "it failed" with no request id; each one costs a log-diving session to even locate.
What breaks
  • Client retry logic guesses: permanent failures get retried (wasted load, duplicate side effects) and transient ones surface to users as hard errors.
  • Message text becomes unchangeable because unknown clients parse it — the provider loses control of its own copy.
  • Support cost scales with traffic: unfindable failures mean every ticket starts with "can you tell us roughly when this happened?"

Design, observe, evolve

A contract decision is incomplete until you know how you would notice it failing and how it changes later.

Design the contract
  • • Define one error envelope (`code`, `message`, `details`, `request_id`) and enforce it with a translation boundary that catches every path out of the process — framework errors, proxy timeouts and dependency exceptions included.
  • • Document the error codes per endpoint as part of the contract, with the same review discipline as response fields; codes are append-only (see [[backward-compatibility]]).
  • • Return the request id in every response, success and failure, and log everything you withheld from the client under that id.
  • • State in the docs, explicitly, that `message` text is not stable — an explicit non-guarantee is the only defense against it becoming one.
Observe in production
  • • Count error responses that bypass the envelope (log a metric in the translation boundary's catch-all path); nonzero means clients are meeting raw internals.
  • • Track error responses by `code` per endpoint; a spike in `internal` is an incident, a spike in a 4xx code is a client integration or docs problem.
  • • Watch support tickets that arrive without request ids — it usually means an error path (often the edge or a proxy) is not carrying the envelope.
Evolve without breaking
  • • New error codes are additive — but only if clients were told from day one to treat unknown codes by falling back on the status class (the enum-evolution rule applied to errors, see [[enum-evolution]]).
  • • The `details` object can grow new fields per code freely; renaming or removing existing ones is a breaking change like any other.
  • • Messages and `doc_url` targets change freely — that is exactly why the contract routes stability through `code` instead.
What it costs
  • • The translation boundary is real code that must be maintained and that can itself fail; its catch-all path needs the most care and gets the least testing by default.
  • • Stable codes are commitments: `card_declined` shipped once is yours forever, so codes need naming review, which slows shipping the first error slightly.
  • • Withholding internals from responses makes provider-side logging non-optional — the debugging detail has to live somewhere findable.

Misconceptions

Claim
“The HTTP status code is the error model — 400 vs 500 tells the client what it needs.”
Reality
Status classes serve generic machinery, and there are not enough of them for domain decisions. One endpoint's 409 can be duplicate_email or stale_version; a client that only sees 409 must guess which recovery to run. Status and body code are layers, not alternatives.
Claim
“Detailed errors help attackers, so vague errors are safer.”
Reality
What leaks internals is *raw* errors (stack traces, SQLSTATE), not *structured* ones. code: "duplicate_email" reveals a fact the attacker can learn anyway by trying to log in; SQLSTATE 23505 on users_email_key reveals your schema. Structure lets you say exactly as much as you mean to.
Claim
“We will standardize errors after launch — it is just formatting.”
Reality
Clients write their error handlers against the first shape they see. After launch, every reshape is a breaking change multiplied by the number of error paths; before launch it is one decision.