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.
Content-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
// 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.
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.
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.
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