Failure as an Interface: A Taxonomy of Errors Callers Can Act On
Every API publishes two contracts. The success path gets the design reviews, the examples, and the changelog. The failure path gets whatever the framework emitted the first time someone threw an exception. Yet the failure path is what callers read under pressure, and it is the part they cannot fix by reading the happy-path documentation.
Treating error output as an interface changes the questions a team asks. Not "did we catch the exception?" but "what can the caller do next, and how do we say it in a form both a person and a retry loop can use?"
Start with who must act
A useful taxonomy begins with agency, not with HTTP status codes. Four kinds of failure cover most production systems, and each implies a different response from the caller and a different response from the team that owns the service.
| Kind | Typical cause | Status | Caller action | Owner action |
|---|---|---|---|---|
| Invalid request | Missing field, out-of-range value, bad format | 400 or 422 | Correct the input; do not retry unchanged | None |
| Transient failure | Rate limit, dependency timeout, brief overload | 429 or 503 | Retry with backoff, honoring retry_after | Watch saturation |
| Invariant violation | Bug, impossible state, data corruption | 500 | Do not retry; report with correlation ID | Page on-call |
| Configuration drift | Missing secret, wrong endpoint, feature flag off in one environment | 500 or 503 | Do not retry; contact the operator | Fix deployment and add a startup check |
Most bad error handling comes from collapsing these into one category. A validation failure marked retryable produces a retry storm. A configuration error reported as a generic 500 sends the caller looking for a bug in code that is working as written.
Stable codes carry the meaning
A message is prose, and prose changes. A code is an identifier, and identifiers must not. Namespace codes by domain and keep them in a registry: orders.invalid_quantity, payments.card_declined, auth.token_expired. Document each one, give it an owner, and deprecate rather than repurpose. Never interpolate variable data into a code; orders.invalid_quantity_500 is a code explosion, not a diagnostic.
Callers should branch on the code, never on the message text. If a client needs the maximum allowed value, that belongs in structured details, not in a sentence that a copy editor might reword next quarter.
Before and after
The common starting point is a string and a status.
{ "error": "Something went wrong" }
It tells the caller nothing about whether to retry, what to change, or whom to ask. A structured replacement for the same validation failure looks like this:
{
"type": "https://errors.example.com/validation",
"code": "orders.invalid_quantity",
"message": "Quantity must be a whole number between 1 and 500.",
"status": 422,
"details": [
{ "field": "quantity", "reason": "out_of_range", "min": 1, "max": 500 }
],
"retryable": false,
"remediation": "Resubmit with a quantity in range; split larger orders into multiple requests.",
"docs": "https://docs.example.com/errors/orders.invalid_quantity",
"correlation_id": "req_01J8Z3K9QP4M"
}
The shape follows the problem-details convention closely enough to be familiar, while adding the fields that change behavior: a stable code, a retryability flag, a remediation hint, and a correlation identifier.
Retryability is a promise, not a suggestion
retryable: true is an instruction that will be followed literally by every client library that trusts you. It should appear only where a retry has a real chance of success, and it should be paired with retry_after in seconds when the server knows the window. A 429 without a retry hint invites clients to invent their own schedule, usually a tight one. A 503 caused by a missing database credential is not transient, however much the status code suggests otherwise.
Retryability should be derived from the failure category rather than set per call site. That keeps behavior consistent as the codebase grows.
Remediation hints direct the next action
A good message states what happened. A good remediation hint states what to do. The distinction matters for machine-generated clients and for support engineers who have never read the service's documentation.
Compare "Invalid request" with "The quantity field must be an integer between 1 and 500; resubmit with a value in that range." The second sentence costs one line and removes a support ticket. For configuration drift, name the missing setting and the environment without printing its value: "Feature checkout_v2 is not enabled in this deployment; enable it in the service configuration before retrying." That is actionable and reveals nothing sensitive.
Correlation IDs close the loop between response and log
Generate a request identifier at the edge, propagate it through every internal call, and return a short form to the client. Log the full identifier with structured fields. When a caller reports a failure, the identifier turns a vague conversation into a query. Include the trace identifier where one exists, but do not confuse the two: a correlation ID identifies a business request; a trace span identifies a unit of work. Both are useful; only one belongs in a user-facing error payload.
Redaction is a schema decision
Most leaks happen because error payloads are built by serializing whatever object failed. The safer approach is an allowlist: each error code declares the detail fields it may include, and everything else is dropped before serialization. Passwords, bearer tokens, full card numbers, and government identifiers never belong in a response, even in development. Account and tenant identifiers should be masked or replaced with an opaque reference. Add a test that feeds known-bad strings through every error constructor and asserts they do not appear in the output.
That test is cheap and catches the regression that matters most.
A review checklist
Before shipping a new failure mode, confirm the following:
- The error has a registered code, an owner, and a documentation page.
- The category is explicit, and its HTTP status and retryability agree with the table above.
- The message is a complete sentence with no stack trace, internal hostname, or SQL fragment.
remediationnames an action the caller can actually take.detailsis allowlisted rather than a dump of the failing object.- A correlation ID is present and matches the server logs.
- Tests assert on the code and structure, not on the message string.
- Client libraries branch on
codeand honorretryableandretry_after.
Errors are read more often than success payloads, usually by someone under time pressure who is trying to decide whether to retry, escalate, or rewrite a request. Designing them deliberately is not polish. It is the part of the API that determines whether a failure stays a minor inconvenience or becomes a support incident.
A service whose failures explain themselves is easier to operate, easier to integrate with, and easier to trust. That is the standard worth holding.