How do you design the error contract for an API that other teams have to program against?
Treat errors as published contract: an HTTP status that classifies the failure, a stable machine-readable code the client branches on, a human message that carries no internal detail, per-field detail for validation, and an explicit statement of whether a retry is safe.
What the interviewer is scoring
- Whether the error body is treated as versioned contract surface rather than as free text for humans
- Does the candidate separate the transport status from a stable application-level error code
- That retryability is stated explicitly instead of left to be inferred from the status class
- Whether validation failures report every offending field rather than only the first
- Does the answer handle partial failure in bulk endpoints without returning success for a failed item
Answer
The half of the contract nobody documents
Every API has two contracts. The success path is designed, reviewed, versioned and documented. The failure path is usually whatever the framework emitted plus whatever exception text reached the serialiser, and it changes without notice because nobody thinks of it as contract. Then a client writes if (message.contains("insufficient")) because that was the only signal available, a refactor rewords the message, and the client breaks on a release that changed no endpoint and no schema.
So the design principle is that the error response is part of the interface. Its shape, its status codes and the set of codes it can return are all things clients depend on, and therefore all things you must be able to evolve deliberately. Everything below follows from that.
What the status code is for
The status code exists to tell an intermediary and a generic client what class of thing happened, without understanding your domain. Proxies, caches, load balancers, SDK retry layers and dashboards all read it, and none of them read your body. That is the whole job, and it means the status code needs to be coarse and correct rather than expressive.
The distinctions that carry weight are whether the caller can fix this by changing the request, whether they need different credentials or permissions, whether the resource exists, whether the request conflicts with current state, and whether the failure is yours rather than theirs. The 4xx and 5xx split is not a formality: it determines whether the client should retry, whether your error budget is consumed, and whether the alert wakes somebody up. Returning 500 for a malformed request pollutes your reliability signal, and returning 400 for a database outage hides an incident.
flowchart TD
A[Request arrives] --> B{Caller authenticated and permitted}
B -- no --> C[401 or 403]
B -- yes --> D{Body parses and types match}
D -- no --> E[400 malformed]
D -- yes --> F{Rules and current state allow it}
F -- no --> G[409 or 422]
F -- yes --> H[Process the request]The gate worth arguing about is the last one, because that is where a syntactically perfect request fails on meaning: a well-formed transfer against an insufficient balance, or an update against a version that has since moved on. Whether you express that as 422 or 409 matters far less than being consistent, since the client is going to branch on your code rather than on the number.
The stable code is what clients branch on
Give every distinct failure a machine-readable identifier that is short, lower-cased, domain-specific and permanent. insufficient_funds, card_expired, plan_limit_reached. Then treat those strings the way you treat a field name: they can be added, they cannot be reworded, and removing one is a breaking change.
The reason the code has to exist separately from the status is that many distinct failures share a status. A 403 might mean the token lacks a scope, the account is suspended, or the resource is in a region the caller cannot reach — three different client behaviours behind one number. The reason the code has to be stable is that clients switch on it, and the reason it must not be the human message is that the human message is for humans and will be translated, shortened and rewritten.
RFC 9457 gives a conventional shape for this — a media type, a type URI identifying the problem class, a short title, a detail for this occurrence, and room for your own members — and adopting it costs nothing and saves the argument. What matters more than the envelope is that you use one for every error the service can produce, including the ones the framework generates before your handler runs. An API with a hand-crafted error shape on the endpoints somebody remembered and a framework default on the 404s and the 415s has two error contracts.
Document the codes per endpoint, and design clients to treat an unknown code as a generic failure of its status class rather than crashing. That forward-compatibility rule is what lets you add a code later without a version bump.
Retryability has to be stated, not guessed
Clients guess badly about retries, and the guesses are expensive in both directions. The common wrong heuristic is "5xx is retryable, 4xx is not". A 409 conflict from an optimistic-concurrency check is worth retrying after a re-read. A 429 is retryable, and the useful thing is when. A 501 is not retryable at all. And a 504 on a non-idempotent operation is the dangerous case, because the request may well have succeeded, so retrying it duplicates the effect.
That last point is why the error contract and the idempotency design are the same conversation. If unsafe operations accept a client-supplied idempotency key, a timeout becomes safely retryable and the client no longer has to reason about it. Without one, the honest thing to tell a client after a 504 is that they must query for the outcome rather than repeat the call, and that instruction belongs in your documentation rather than in each client's folklore.
Where a wait is required, say so with Retry-After rather than leaving the client to invent a backoff, and return it on 429 and on 503 alike. Where you know a failure is terminal, say that too: a boolean or an enumerated retry hint in the error body is cheap, and it is far more reliable than a table of status codes maintained separately in five languages.
Validation and partial failure
For a rejected payload, return every problem at once, each tied to the exact location that caused it using a path expression rather than a prose description. A validator that stops at the first error turns one round trip into five for a form with five bad fields, and the client cannot render a useful form because it only knows about one of them.
Bulk endpoints are where error contracts most often go wrong. Fifty items are submitted, forty-seven succeed, three fail, and the tempting response is 200 with the failures listed inside. That choice costs two things simultaneously. Your monitoring counts the call as a success, so a rising failure rate inside bulk requests is invisible on every dashboard you own. And generic client middleware — SDK error handlers, retry interceptors, integration tests — treats a 2xx as fine and never inspects the body, so the failures are silently discarded by code that was written correctly for every other endpoint.
The two defensible designs are all-or-nothing, where the whole batch fails atomically on any bad item and the status reflects that, or explicitly per-item, using a multi-status response in which each entry carries its own status and error code, so the outer status advertises that the body must be read. What is not defensible is a plain success carrying failures, because it lies to exactly the layers that were built to trust it.
What never goes in the body
Exception class names, stack frames, SQL fragments, internal hostnames, queue names and upstream vendor errors are all reconnaissance, and messages that echo back whether an account exists are an enumeration oracle. What you owe the caller instead is a correlation identifier: return it on every error, log it with the full internal detail on your side, and put it in your support runbook. The caller can then quote one opaque string and your engineer can retrieve everything, which is strictly better for both of you than a leaked stack trace.
Errors are contract, and the shape of them is what clients build logic on. Pick a status class an intermediary can act on, a stable code your client can switch on, an explicit retry signal so nobody has to guess, and never return a success status for work that failed.
Likely follow-ups
- You discover a client is retrying on 400. How do you find that out, and what do you change?
- Where do you draw the line between 400 and 422, and does the distinction help the client?
- How do you add a new error code without breaking clients that switch on the current set?
- What goes in the response so support can trace one failed call, without leaking anything internal?
Related questions
- Clients are asking for page 4,000 of your /orders collection. How is that endpoint paginated, and what would you change?mediumAlso on http and rest4 min
- Give me a subclass that the compiler accepts but that still breaks the Liskov substitution principle. What rule does it break?mediumAlso on contracts and api-design4 min
- Your logs are full of Cannot set headers after they are sent. Walk me through how a request gets into that state.mediumAlso on http and error-handling4 min
- Which status codes and method semantics do you insist on in a code review, how do the caching headers fit together, and is the API you just described REST?mediumAlso on http and rest5 min
- Customers need to upload two-gigabyte files to your API over a flaky connection. What does that endpoint look like, and what breaks if you just accept a POST body?hardAlso on http6 min
- You stream the model's answer token by token to the browser. What does that change about error handling, guardrails and cost?hardAlso on error-handling5 min
- The same domain has to be exposed to a mobile app, a partner integration and internal service-to-service traffic. Where does GraphQL fit, where does gRPC, and where does neither?hardAlso on api-design7 min
- A client times out and retries your POST /payments call. How do you make that endpoint idempotent?hardAlso on http5 min