Errors a Client Can Program Against

A problem+json body with a stable type, per-field detail, and a clear answer to whether retrying could ever help.

SyntaxContent-Type: application/problem+json -> { "type", "title", "status", "detail", "instance", ... }

An error only a human can read forces every client into string matching, and then your support team cannot reword a message without breaking an integration. The error shape is part of the contract: clients branch on it, log it, and decide whether to retry.

problem+json

RFC 9457, Problem Details for HTTP APIs (July 2023, obsoleting RFC 7807), defines application/problem+json with five members: type, a URI naming the problem class; title, short and human and identical for every occurrence; status; detail, about this occurrence; and instance, a URI for this occurrence. Anything else you add is an extension member, and the spec requires clients to ignore extensions they do not recognise — which is exactly what makes the shape safe to grow.

A code separate from the message

The type URI, or a short code extension, is the stable identifier. detail is not stable: it is prose, it gets localised, it gets reworded. Validation failures belong in a list with one entry per field — a pointer and a code each — rather than concatenated into one sentence the client has to split.

Say whether retrying can help

This is the field most APIs omit and every client needs. A 412 on a stale write is worth retrying after a re-read. A 422 on a malformed amount will never succeed. A 503 is worth retrying with backoff. State it explicitly, with Retry-After where you know the delay, because a client left to guess will either hammer you or abandon a transient failure.

An absent type defaults to about:blank, meaning "nothing beyond the status code".

Example

Example · javascript
// errors.js — one error vocabulary, one serialiser.
const BASE = 'https://api.example.com/problems/';

export class ApiError extends Error {
  constructor(status, code, { detail, retryable = false, retryAfter, errors } = {}) {
    super(code);
    Object.assign(this, { status, code, detail, retryable, retryAfter, errors });
  }
}

// Registry, so `code` is a closed set and `title` never varies per occurrence.
const TITLES = {
  'validation-failed':  'One or more fields are invalid',
  'order-not-open':     'Order is not open',
  'stale-etag':         'Resource was modified by someone else',
  'rate-limited':       'Too many requests',
  'upstream-unavailable':'A dependency is temporarily unavailable',
};

// Express 5 error middleware — must be mounted last.
export function problemHandler(err, req, res, _next) {
  const e = err instanceof ApiError
    ? err
    : new ApiError(500, 'internal-error', { retryable: true });

  if (e.retryAfter != null) res.set('Retry-After', String(e.retryAfter));

  const body = {
    type: BASE + e.code,                 // the stable thing clients switch on
    title: TITLES[e.code] ?? 'Unexpected error',
    status: e.status,
    detail: e.detail,                    // prose; may be reworded or localised
    instance: `/requests/${req.id}`,     // ties the error to your logs
    // Extension members. Clients must ignore ones they do not know, which is
    // what lets you add here later without a version bump.
    code: e.code,
    retryable: e.retryable,
    ...(e.errors ? { errors: e.errors } : {}),  // spread === Object.assign(body, {...})
  };

  if (e.status >= 500) req.log.error({ err, problem: body }, 'request failed');
  res.status(e.status).type('application/problem+json').json(body);
}

When to use it

  • A mobile client that cannot tell which 400 responses are worth retrying
  • Replacing a mix of plain-text and ad-hoc JSON errors across several services
  • Returning field-level validation messages a web form can attach to inputs
  • Giving support a request id in the error body that matches the server logs

More examples

Per-field validation detail

The `pointer` values are JSON Pointers into the request body, so a form can attach each message to the right input without parsing prose. Use this shape the moment a request body has more than two fields — a single `detail` string forces the UI to either show everything in one toast or guess at splitting it.

Example · http
POST /orders
{"email": "not-an-email", "quantity": 0, "currency": "XYZ"}

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://api.example.com/problems/validation-failed",
  "title": "One or more fields are invalid",
  "status": 422,
  "detail": "3 fields failed validation.",
  "instance": "/requests/01HV8ZQ4",
  "code": "validation-failed",
  "retryable": false,
  "errors": [
    {"pointer": "/email",    "code": "format",      "detail": "must be an email address"},
    {"pointer": "/quantity", "code": "min",         "detail": "must be at least 1"},
    {"pointer": "/currency", "code": "enum",        "detail": "must be one of USD, EUR, GBP"}
  ]
}

The retryable flag doing real work

Note the default branch: a client written today against codes that exist today still behaves correctly when you add a new one, because `retryable` carries the only decision it cannot afford to get wrong. That fallback is what makes the error vocabulary extensible in practice.

Example · javascript
async function call(url, init) {
  const res = await fetch(url, init);
  if (res.ok) return res.json();

  const ct = res.headers.get('content-type') ?? '';
  const problem = ct.includes('application/problem+json') ? await res.json() : null;

  // Branch on the stable code, never on `title` or `detail`.
  switch (problem?.code) {
    case 'validation-failed':
      throw new UserFacingError(problem.errors);      // show the fields; never retry
    case 'stale-etag':
      return null;                                   // caller re-reads and re-applies
    case 'rate-limited':
    case 'upstream-unavailable':
      throw new RetryableError(Number(res.headers.get('retry-after')) || 1);
    default:
      // Unknown code: fall back to the one signal the server promised.
      if (problem?.retryable) throw new RetryableError(1);
      throw new Error(`${res.status} ${problem?.title ?? res.statusText}`);
  }
}

Documenting a problem type

Keep the type URIs on a path you are willing to serve forever, because they end up hardcoded in client switch statements and in other people's runbooks. The second block is worth knowing because an absent `type` is legal and common — client code must not assume the member is present.

Example · http
GET /problems/order-not-open
200 OK
Content-Type: text/html

# The type URI resolves to human documentation. Nothing in the protocol
# requires it to resolve, but integrators will paste it into a browser,
# and "404" is a bad first impression of your error handling.

# Minimal legal problem document — `type` omitted means about:blank,
# i.e. "no more information than the status code conveys":
HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
Retry-After: 30

{"title": "Service Unavailable", "status": 503}

Discussion

  • Be the first to comment on this lesson.