Software Architecture · API Design

Error Contracts: Problem Details, Stable Codes, and Retry Semantics

How to design an error payload that machines can branch on and humans can act on, and why retryability belongs in the contract rather than in the status code alone.

An error response serves three audiences at once, and they want incompatible things. The end user wants a sentence they can understand and act on. The calling program wants a stable identifier it can branch on. The operator wants enough context to correlate the failure with a log line and a trace. A single message string serves none of them well: it is too technical for the user, too fragile for the program, and too unstructured for the operator.

That tension is why error responses deserve a designed contract rather than an ad hoc string. RFC 9457 defines Problem Details, a media type and a small set of members that carry the machine-readable part, and leaves extension members for everything else.

The transport, and what it does not decide

Problem Details uses the media type application/problem+json and defines five members: type, a URI reference identifying the problem type; title, a short human-readable summary that should be stable for the type; status, the HTTP status code, repeated in the body for consumers that log only bodies; detail, an explanation specific to this occurrence; and instance, a URI identifying this occurrence.

The specification is explicit about what these are for. type is the machine-readable discriminator and, when it is a dereferenceable URI, the place where documentation for that problem type lives. title is meant to stay constant for a given type, which makes it suitable for logs but not for variation. detail carries the occurrence-specific explanation, while instance identifies the occurrence and extension members carry problem-specific data; the specification asks that detail help the client correct the problem rather than debug the server.

What Problem Details deliberately does not do is enumerate the problem types. It provides a container. The taxonomy inside the container is the API’s own design decision, and it is the part that determines whether callers can write reliable branching logic.

Other error models make the same separation between a stable code and human-facing text. Google’s AIP-193 pairs a canonical status code with a human-readable message and structured details, and gRPC’s status codes are a small, closed set with a fixed meaning per code. Both give callers a stable discriminator rather than a sentence to parse.

A taxonomy with a shallow hierarchy

A useful error taxonomy is shallow and stable. Each type represents a condition that a caller might handle differently, not a distinct code path inside the server. Two failure conditions that require the same client response should share a type; two that require different responses must not.

The granularity that tends to work separates by remedy rather than by cause. “The request was rejected because the referenced entity does not exist” is one type, whether the entity was deleted, never created, or belongs to another tenant, because in all three cases the caller’s action is the same: stop and report. Splitting it into three types means every client writes three branches that do nothing different.

The opposite failure is just as damaging: one type for all client errors, with the discriminator buried in detail. That is a plain-text API wearing a structured hat.

The URI naming convention matters more than it looks. If type values are absolute URIs under a domain the provider controls, the identifiers are globally unambiguous and can be dereferenced to documentation. If they are bare strings such as "validation_failed", they are easier to read in logs but not globally unique, and they carry no location to dereference. RFC 9457 permits relative references, resolved against the document’s base URI, which for an HTTP response is normally the request URI; that keeps the identifiers on the API’s own origin, which works when the documentation lives there and produces a dead end when it does not.

Stable codes versus human text

A persistent error design mistake is allowing a client to match on detail. It arrives through no decision of the provider: the message is the only informative member, so consumers use it. The next release rewords a message, and a caller that matched a substring stops recognising a condition it used to handle.

The distinction is the error-specific case of the split between contractual and incidental observations described in where an API contract ends. The contract should state, in the documentation, that detail and title are human-facing and may change without notice. That statement is only credible if a machine-readable alternative exists. Where it does not, the documented warning is false in practice.

The same rule applies to the type URI itself. Its stability is a promise: a problem type that is renamed is a breaking change for every client that branches on it, however compatible the surrounding payload looks. Because the URI is a string in a field, schema comparison tools will not flag the change unless they compare enumerated values.

Retryability is a separate axis

A status code carries a coarse retry hint. RFC 9110 says a 5xx indicates a server fault, and generic clients treat server faults as retryable and client faults as not. That heuristic is wrong often enough to matter.

A 503 Service Unavailable with a Retry-After header is a clear retry instruction. A 500 Internal Server Error caused by a deterministic bug is not retryable at all: the same request will fail identically until the bug is fixed. A 429 Too Many Requests is retryable after the interval named in Retry-After, and retrying earlier wastes the caller’s budget. A 409 Conflict from a version check is retryable only after re-reading the current state, which is a different operation, not a repeat.

Because status codes cannot express these distinctions, some APIs carry an explicit boolean or a retry hint in the error payload. The value is real: it turns a decision that every client implements badly into a decision the provider makes once. The cost is that the flag becomes part of the contract and must be correct for every error the API can produce, including the ones emitted by infrastructure in front of the application, which will not have the field at all. Clients still need a status-code fallback for those.

Retried writes bring their own requirement, since a repeat is only safe when the operation is idempotent or carries a key; that mechanism is covered in idempotency keys for retried writes.

Many failures at once

Validation is the case where one error response describes several problems. Reporting only the first failure forces the caller into a repair-and-resubmit loop, one round trip per mistake, which is a poor experience for an interactive client and a slow one for a batch import.

Problem Details has no standard array for this, so APIs extend it. A common shape adds an errors member, an array of objects each carrying a pointer to the offending location, a code, and a message.

{
  "type": "https://api.example.com/problems/validation-failed",
  "title": "Request validation failed",
  "status": 422,
  "detail": "3 fields were rejected.",
  "instance": "/requests/01J8Z3K4",
  "errors": [
    { "pointer": "/billing/postal_code", "code": "too_long", "detail": "Maximum 12 characters." },
    { "pointer": "/lines/2/quantity", "code": "out_of_range", "detail": "Must be at least 1." },
    { "pointer": "/currency", "code": "unsupported", "detail": "Supported: EUR, GBP, USD." }
  ]
}

The pointer syntax deserves care. JSON Pointer, defined in RFC 6901, gives an unambiguous path into the request document and handles array indices and escaped member names. A path expressed as a dotted string cannot distinguish a member named a.b from a nested object, and cannot address an array element without inventing syntax.

Two properties make this shape useful. The array is ordered consistently, or the documentation says the order is not significant. And the code values come from a closed, documented set per field type, so a form renderer can attach the right message. A validation response whose codes are free text is a validation response that clients will localise by string matching.

The count in detail should match the array length, or the array should be explicitly truncated with a stated maximum. A response that says “3 fields were rejected” and lists two is the kind of detail that erodes trust in the whole contract.

Not leaking internals

Error responses are a classic information leak. A stack trace, a SQL fragment, or an internal hostname in detail gives an attacker structural information about the deployment. It also gives legitimate callers information they will start to depend on.

The separation that works is between the response and the log. The response carries the stable type, a safe human summary, and a request identifier. The log carries the exception, the stack, the upstream response, and the same identifier. An operator who needs the internals joins on the identifier, and no caller ever sees a class name.

instance is the natural home for that identifier, and it should be a URI that an authorised operator can dereference rather than a bare UUID, so the correlation is one step instead of a manual search. Where the identifier is exposed to untrusted callers, it must be unguessable and must not itself encode sensitive data.

What the contract has to state

An error contract is complete when it names the media type, lists the problem types with their meanings and remedies, states whether detail and title are stable, defines the extension members and their ordering guarantees, and says which statuses each operation can produce. The last item is routinely omitted from API descriptions, which is why clients produced from the description tend to model only the success response and leave callers to parse errors by hand. Describing failure responses with the same rigour as successes is the cheapest improvement available to an API.

References

  1. RFC 9457: Problem Details for HTTP APIs — IETF
  2. RFC 9110: HTTP Semantics — IETF
  3. Google API Improvement Proposals, AIP-193: Errors — Google
  4. gRPC documentation: Status Codes — gRPC project