What a consistent error contract is and why clients depend on it
A consistent error contract is the part of your API that describes failure. It has three layers. The HTTP status code carries coarse meaning that any HTTP-aware component understands: retry, fix the request, or authenticate. Its semantics are defined in RFC 9110. A stable, machine-readable error code identifies the precise condition, such as insufficient_funds versus card_expired, both of which might be a 422. A Problem Details body (RFC 9457) is the envelope that carries the code, a human-readable explanation and any structured extensions in a predictable shape.
API error handling is often treated as a logging concern: capture the stack trace, return something readable, move on. That view misses how clients behave. Callers branch on errors. A mobile app shows a "card expired" screen when it sees a specific code. A job runner retries on 503 and gives up on 400. A generated SDK maps codes to typed exceptions. If you rename a code, move it to a different field, or change a 409 to a 400, those branches stop firing. Nothing fails loudly, and the caller does the wrong thing. That is a breaking change, exactly like removing a field from a success payload.
A formal contract pays off most for teams with external consumers you cannot coordinate releases with, for organizations running many services behind one API surface, and for anyone shipping generated SDKs.
A lighter approach is reasonable for an internal API with a single client that deploys alongside it, or for a prototype whose interface will change anyway.
How it works: status codes, Problem Details and error codes together
The status code is the first layer of the contract. Under RFC 9110 it states the general class of the outcome. Proxies, caches, client retry policies and monitoring often act on the status code alone, without parsing the body. A 200 with an error body hides the failure from all of them, so never send one.
| Condition | Status | Client action |
|---|---|---|
| Malformed JSON, wrong types | 400 | Fix the request |
| Resource absent | 404 | Stop, or create it |
| Version or state conflict | 409 | Re-read, then retry |
| Well-formed but fails business rules | 422 | Fix the input |
| Rate limit exceeded | 429 | Back off, honour Retry-After |
| Dependency down, overloaded | 503 | Retry with backoff |
Two rows are judgment calls. Some teams use 400 for all input errors, and others reserve 422 for semantic validation. Some return 404 instead of 403 so they don't confirm that a resource exists. Neither choice is universal. Record yours in the error catalog and apply it consistently.
The body carries the detail. RFC 9457, which obsoletes RFC 7807, defines the application/problem+json media type and five standard members:
type: a URI identifying the problem typetitlestatusdetailinstance
Extension members are permitted. Use them for:
- a stable
code, such asORDER_ALREADY_SHIPPED - a
request_idfor log correlation - an
errorsarray of{field, code, message}entries
A conflict response, for example, carries status 409, type set to /problems/order-already-shipped, title "Order already shipped", that code, and the request ID. Clients branch on type or code. title and detail are for humans and may change.
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: en
{
"type": "/problems/validation-failed",
"title": "Request failed validation",
"status": 422,
"detail": "2 fields failed business-rule validation.",
"instance": "/orders/ord_8f3a21",
"code": "VALIDATION_FAILED",
"request_id": "req_01HZX4K9QW2M7N",
"errors": [
{
"field": "quantity",
"code": "QUANTITY_EXCEEDS_STOCK",
"message": "Requested 12 units; 5 are available."
},
{
"field": "shipping.country",
"code": "COUNTRY_NOT_SERVED",
"message": "Orders cannot ship to this country."
}
]
}
An RFC 9457 problem response for a 422. It uses the five standard members and adds extension members for a stable code, a request ID and field-level errors.
Designing error codes and maintaining an error catalog
Codes are only a safe branching key if they stay stable, so give them strict rules:
- Namespaced and machine-readable. Use identifiers like
PAYMENT_CARD_DECLINEDorORDER_ALREADY_SHIPPED. The prefix groups codes by domain and prevents collisions between teams. - One documented condition per code. If a code can mean two things, clients cannot act on it correctly.
- Never reused. A retired code stays retired. Giving it a new meaning silently breaks clients that still handle the old one.
- Never match on messages. Once
detailis localized into French or Japanese, string matching fails outright. Clients need the code.
The catalog as single source of truth
Keep every code in one machine-readable catalog file under version control. Each entry records:
- the code
- the HTTP status
- its meaning
- whether it is retryable
- the action the client should take
- a resolvable type URI
For example, PAYMENT_CARD_DECLINED maps to 402. It means the issuer refused the card and is not retryable with the same card. The client should ask for another payment method. Its type URI is /problems/payment-card-declined.
CI generates documentation pages, SDK enums and server constants from this file. Nobody hand-edits the copies, so they cannot drift. A build that raises a code missing from the catalog fails.
| Aspect | Few broad codes | Hundreds of fine-grained codes |
|---|---|---|
| Client branching | Coarse; clients must parse detail |
Precise per-condition handling |
| Catalog upkeep | Low | High; needs ownership and review |
| Risk | Overloaded meanings | Near-duplicates, unused codes |
Hosting a documentation page at every type URI costs writing and maintenance effort, but each URI then explains itself. Using about:blank costs nothing, but it tells the client nothing beyond the status code. In that case the code field carries all the meaning.
# gen_errors.py: runs in CI. catalog.json is the single source of truth.
# Example entry in catalog.json:
# {"code": "PAYMENT_CARD_DECLINED", "status": 402, "retryable": false,
# "meaning": "The issuer refused the card.",
# "action": "Ask the user for another payment method.",
# "type": "/problems/payment-card-declined"}
import json, re, sys
REQUIRED = {"code", "status", "meaning", "retryable", "action", "type"}
CODE_RE = re.compile(r"^[A-Z]+(_[A-Z]+)+$") # DOMAIN_CONDITION, e.g. ORDER_ALREADY_SHIPPED
entries = json.load(open("catalog.json"))
seen = set()
for e in entries:
missing = REQUIRED - e.keys()
if missing or not CODE_RE.match(e["code"]) or e["code"] in seen:
sys.exit(f"invalid catalog entry: {e.get('code')} missing={missing}")
seen.add(e["code"]) # one entry per code; retired codes stay in the file
# SDK enum: clients branch on these values, never on the localized detail text.
with open("sdk/errorCodes.ts", "w") as f:
f.write("// GENERATED from catalog.json. Do not edit.\nexport enum ErrorCode {\n")
for e in entries:
f.write(f' {e["code"]} = "{e["code"]}",\n')
f.write("}\n")
# Server constants: handlers import these, so raising an unlisted code fails the build.
with open("server/error_codes.py", "w") as f:
f.write("# GENERATED from catalog.json. Do not edit.\n")
for e in entries:
f.write(f'{e["code"]} = {{"status": {e["status"]}, "type": "{e["type"]}", '
f'"retryable": {e["retryable"]}}}\n')
# Docs: one page per type URI, so each URI resolves to an explanation.
for e in entries:
slug = e["type"].rsplit("/", 1)[-1]
with open(f"docs/problems/{slug}.md", "w") as f:
f.write(f"# {e['code']}\n\nHTTP {e['status']}. {e['meaning']}\n\n"
f"Retryable: {'yes' if e['retryable'] else 'no'}. {e['action']}\n")
A CI script validates catalog.json and generates the SDK enum, server constants and per-type-URI docs pages from it, so the copies cannot drift from the catalog.
Validation errors and retry semantics as part of the contract
Validation errors: report every failure at once
A validation failure should return every field problem in one response. If the server stops at the first error, the user fixes one field, resubmits, and only then sees the next error. Add an errors array to the Problem Details body. Each entry needs three things:
pointer: a JSON Pointer (RFC 6901) to the input that failed, such as/items/2/quantity. For query parameters or headers, use aparameterorheadername.code: a stable, catalogued value, such asquantity_below_minimum.message: human-readable text. Clients may display it but must never parse it.
An example 422 body: type set to your validation problem URI, code set to validation_failed, and errors containing {"pointer": "/email", "code": "email_invalid", "message": "Email address is not valid"} and {"pointer": "/items/2/quantity", "code": "quantity_below_minimum", "message": "Quantity must be at least 1"}. The client maps each pointer to a form field and picks localized text by code.
Retry semantics
Mark every catalog entry as retryable or not. That way clients don't have to guess from the status code. Send Retry-After with 429 and with 503 whenever the server can estimate when capacity returns. Some codes are only safe to retry if the request is idempotent, as defined in RFC 9110, or if it carries an idempotency key. Timeouts and 502 responses are typical cases. Document those codes as conditionally retryable. Otherwise a retried POST can charge a customer twice.
On the client:
- Look up
codein the known set. If it is found, handle it specifically. - If the code is unknown, fall back to the status class. Treat 4xx as a generic client error and do not retry. Treat 5xx as a generic server error and retry only if the request is idempotent.
- Always honor
Retry-Afterover local backoff when it is present.
type FieldError = { pointer?: string; parameter?: string; code: string; message: string };
type Problem = { type: string; code?: string; errors?: FieldError[] };
type RetryPolicy = "never" | "always" | "if_idempotent";
// Mirrors the published error catalog: every known code declares its retry policy.
const CATALOG: Record<string, RetryPolicy> = {
validation_failed: "never",
rate_limited: "always", // 429
service_unavailable: "always", // 503
upstream_timeout: "if_idempotent", // safe only for idempotent requests or ones with an idempotency key
};
type Action =
| { kind: "show_field_errors"; byField: Map<string, string> } // field -> error code
| { kind: "retry"; afterMs: number }
| { kind: "fail"; generic: "client" | "server" };
function retryAfterMs(res: Response, fallbackMs = 1000): number {
const h = res.headers.get("Retry-After");
if (!h) return fallbackMs;
const secs = Number(h);
if (Number.isFinite(secs)) return secs * 1000; // delta-seconds form
const at = Date.parse(h); // HTTP-date form
return Number.isNaN(at) ? fallbackMs : Math.max(0, at - Date.now());
}
function decide(res: Response, body: Problem, idempotent: boolean): Action {
if (body.code === "validation_failed") {
// Map by pointer, not message; localized text is looked up by code.
const byField = new Map((body.errors ?? []).map(e => [e.pointer ?? e.parameter ?? "", e.code]));
return { kind: "show_field_errors", byField };
}
// Unknown codes fall back to the status class: 4xx never retried, 5xx only if idempotent.
const policy: RetryPolicy = (body.code && CATALOG[body.code])
|| (res.status >= 500 ? "if_idempotent" : "never");
if (policy === "always" || (policy === "if_idempotent" && idempotent)) {
return { kind: "retry", afterMs: retryAfterMs(res) };
}
return { kind: "fail", generic: res.status >= 500 ? "server" : "client" };
}
A client-side sketch that maps validation errors to form fields by pointer and code, honors Retry-After, retries conditionally retryable codes only for idempotent requests, and falls back to the status class for unknown codes.
Implementing it across services: shared middleware, edge mapping and contract tests
Put the mapping in one place per service: a shared middleware or library that sits at the service edge and converts internal exceptions into the contract. Handlers throw domain exceptions. They never build error bodies themselves. This keeps formatting decisions out of business code. The mapping table is explicit, so an exception type maps to a status, an error code and a safe detail. Anything not in the table falls through to a default: a generic 500 with a stable code, a correlation ID and no stack trace, class name or query text.
handler ──throws──> DomainError / unknown exception
│
[error middleware]
known type → mapped code + status
unknown → generic 500, no internals
│
application/problem+json ──┐
▼
client <── load balancer / gateway / proxy ─┘
│
└─ own 502/504 (often HTML) ── bypasses contract
Infrastructure in front of the service generates its own responses, such as 502 and 504 (RFC 9110). Either configure those layers to emit contract-shaped bodies, or document that clients must fall back to status-class handling when the body does not parse.
Contract tests should assert error shapes the same way they assert success shapes. Check the media type, the required members and the code values for each documented failure. Also send malformed input to confirm that the default 500 path leaks nothing.
The shared library introduces version skew, especially when you have implementations in several languages. Treat the catalog, not any one library, as the source of truth, and test every implementation against it. When you wrap upstream dependency errors, translate them into your own codes. Do not pass through the upstream codes, because clients would then depend on a vendor you might replace. Do preserve actionable facts, such as "retryable" or the upstream Retry-After, so the client can still act on them.
import { randomUUID } from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
// Handlers throw these; they never build error bodies themselves.
export class DomainError extends Error {}
export class NotFoundError extends DomainError {}
export class ConflictError extends DomainError {}
// Explicit mapping table: exception type -> status, stable code, safe detail.
const MAPPING = new Map<Function, { status: number; code: string; detail: string }>([
[NotFoundError, { status: 404, code: 'resource_not_found', detail: 'The requested resource does not exist.' }],
[ConflictError, { status: 409, code: 'state_conflict', detail: 'The resource was modified by another request.' }],
]);
export function errorMiddleware(err: unknown, req: Request, res: Response, _next: NextFunction) {
const correlationId = req.get('x-request-id') ?? randomUUID();
const entry = err instanceof Error ? MAPPING.get(err.constructor) : undefined;
// Unknown exceptions: log full details server-side only, keyed by correlation ID.
if (!entry) console.error({ correlationId, err });
// Default path: generic 500 with no stack trace, class name or query text.
const { status, code, detail } = entry ?? {
status: 500,
code: 'internal_error',
detail: 'An unexpected error occurred.',
};
// Set the media type explicitly; res.json keeps an existing Content-Type.
res.status(status).type('application/problem+json').json({
type: `
title: code,
status,
detail,
code,
correlation_id: correlationId,
});
}
An Express error middleware that maps known domain exceptions to contract-shaped problem+json responses through an explicit table and sends everything else to a generic 500 that carries a correlation ID and no internal details.
Security, information exposure and evolving the contract safely
Error bodies are output that attackers can read, so treat them as part of your attack surface. A stack trace, SQL fragment, internal hostname or raw upstream message tells an attacker which framework, database and topology you run. Put that detail in the logs, return a request ID in the body or a response header, and let support use the ID to find the log entry. This gives on-call engineers everything they need without making the response body verbose.
Masking also needs a deliberate decision. RFC 9110 allows a server to return 404 instead of 403 to hide that a forbidden resource exists (RFC 9110). Use this for resources whose existence is itself sensitive, and apply it the same way everywhere, or the mismatch will give the secret away. Enumeration works the same way. A message such as "email already registered" helps a real user, but it also confirms accounts to anyone who asks. On sign-up and password-reset endpoints, return a neutral response and send the specific detail through a channel only the account owner can see.
| Change | Compatible? | Why |
|---|---|---|
| Add a new error code | Yes | Clients should fall back on the status code |
| Add an optional extension member | Yes | Clients ignore members they don't recognize |
Reword detail or title |
Yes | Clients shouldn't parse these as text |
| Rename or remove a code | No | It breaks client branching logic |
| Map a code to a different status | No | It changes retry and handling behavior |
You don't need a separate version number for the error format if you follow two rules: only make additive changes, and announce codes before you deprecate them. Versioning errors on their own adds negotiation logic that few clients will use, so tie any breaking error change to a major API version instead.
Alternatives and the impact on cost and engineering time
How it compares
| Approach | Machine-readable cause | Up-front cost | Cost pushed to clients |
|---|---|---|---|
| Ad hoc JSON bodies | Varies per service | None | High: per-endpoint parsing, string matching |
| Status codes only | Coarse (one number) | None | Medium: can't tell "card declined" from "card expired" |
GraphQL errors array |
Only if you standardise extensions |
Low–medium | Medium: HTTP 200 hides failures from generic tooling |
gRPC status + rich details (ErrorInfo, BadRequest, RetryInfo) |
Yes | Medium | Low, but HTTP/JSON consumers need transcoding |
| Problem Details + error catalog | Yes | Medium | Low |
The alternatives defer the cost rather than remove it. With ad hoc bodies or status codes alone, every client team rebuilds error classification on its own and gets it slightly different. The catalog approach pays once for the catalog, code generators, hosted documentation, translations of title strings, and release coordination for the shared library. The return comes from fewer "what does this error mean?" tickets, SDK enums that turn new codes into compile-time signals, faster triage when a request ID is in every body, and fewer incidents caused by a silently changed error shape.
Illustrative estimate (assumed numbers, not a benchmark). Assume the following:
- 40 error-related support tickets per month (T)
- 50% of those tickets avoided (f)
- 1.5 engineer-hours per ticket (h)
- 2 breaking incidents avoided per year at 16 hours each (I × H)
- 120 hours to build the catalog, generators and docs (B)
- 4 hours per month of upkeep (U)
Monthly net saving = T·f·h + (I·H)/12 − U = 30 + 2.7 − 4 ≈ 28.7 h.
Break-even = B ÷ monthly net saving = 120 ÷ 28.7 ≈ 4.2 months.
With low ticket volume or a single client team, f and T shrink and break-even may never arrive. In that case, status codes plus a request ID may be enough.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to api error handling, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Consumers learn about a breaking change at review time rather than from production errors.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.