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 type
  • title
  • status
  • detail
  • instance

Extension members are permitted. Use them for:

  • a stable code, such as ORDER_ALREADY_SHIPPED
  • a request_id for log correlation
  • an errors array 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_DECLINED or ORDER_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 detail is 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 a parameter or header name.
  • code: a stable, catalogued value, such as quantity_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:

  1. Look up code in the known set. If it is found, handle it specifically.
  2. 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.
  3. Always honor Retry-After over 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.