Basis Lab

Answers · The network and the web

How do I design an error response that a client can actually handle?

In shortGive every failure a fixed code a program can branch on and say plainly whether trying again could help, then put the human explanation last.

Return a stable code the program can match on, a boolean saying whether trying again could possibly help, and enough specificity in that code to say whether the caller or the service has to act. The readable sentence is the last field you fill in and the only one you are free to reword. A caller's program has microseconds to decide whether to retry, fall back, alert or show something, and it decides by comparing one value; the person reads the same response hours later in a log and needs a sentence.

The smallest response that satisfies both audiences has about seven fields.

FIG 1One failure, both audiences served
plaintext
status 422

code: order_item_out_of_stock
retryable: false
title: not enough stock to fulfil this order
detail: 3 units of the requested item are available, 5 were asked for
field: items.2.quantity
request_id: 0f3a9c21-7b44-4e2d-9a18-6c0e5d7b1f42
documentation: errors/order-item-out-of-stock
Exactly one line here is a promise to the caller. Everything else can be improved at any time.

The code is the contract and nothing else is. A caller writes a comparison against order_item_out_of_stock and that comparison has to keep working for years, so the string can never be reworded, never be reused for a second situation, and never widened to cover several. If that one code also covered an expired card and a malformed quantity, the branch that offers the customer a smaller quantity would fire on the wrong failures. The title, by contrast, can be rewritten on any Tuesday, because nobody is permitted to match on it.

Whether trying again could help

A caller has exactly two useful questions when a request fails: could this have worked if I had tried again, and if not, what do I change. They cannot answer the first from the outside, because a thirty-second capacity problem and a permanently malformed field look identical from the far end of a socket. Only you know which one it is, so only you can say.

The cost of omitting it is measurable. Take 1,000 requests of which 5 per cent fail: that is 50 failures. A caller with no flag retries each of them three times, which adds 150 requests that cannot possibly succeed, a 15 per cent load increase buying nothing but latency for everybody else. The same caller given retryable: false on those 50 sends zero.

Retryability is not a property of the status code either. A 503 can mean thirty seconds of overload or an endpoint retired on Tuesday, and the number is identical in both cases. Send a wait time alongside a true flag: fifty callers told to wait thirty seconds mostly wait thirty seconds, and what would have been a stampede becomes a queue.

FIG 2Four failures, four different pieces of caller code
stepwhat happenedcoderetryablewhat the caller doeswhat happened
1quantity was a word, not a numberinvalid_field_typenofixes the request and stopsA programming mistake on their side. The field pointer saves them guessing which of forty attributes was wrong.
2the token has expiredtoken_expiredyesrefreshes the token, then retriesRetryable, but only after an action, which is why the flag alone is not enough and the code has to name the action.
3the order has already shippedorder_not_modifiablenotells the user, offers a return insteadThe valuable one. A specific code lets the caller build a sensible path through a situation that is completely normal in the business.
4the database was unreachableinternal_erroryeswaits and retries, reports if it persistsYour emergency. Note how little the caller is told, deliberately, and how much the request identifier lets them give you.
4 steps
The last column is the point. Each entry there exists only because the response distinguished the cases; with one generic failure all four collapse into the same pointless retry.

Whose problem it is

Four classes cover almost everything, and they want four different reactions. Three of them are caused by the caller and one by you; of the four, two can succeed later with the request unchanged, and two need the caller to change something.

The class that gets misclassified is the third: the request is valid, the caller is entitled, and the current state forbids it anyway, because the order has already shipped. It looks like a bad request and it is nothing of the sort. Nothing the caller changes will help, and it is not their error either. Give it a code of its own, because their sensible response is neither to retry nor to correct the payload but to offer the customer a return.

The version nearly everybody ships is a single message string and nothing else, and it fails for a reason that has nothing to do with the quality of the sentence. The caller's program has to branch on something, and the only field available is prose, so it branches on prose: a substring check for stock. That check now depends on your wording. Somebody improves the message to 'only 3 of the 5 requested units are available' and the substring survives by luck; a later copy edit drops the word and a customer's integration quietly stops offering the smaller quantity. No exception is thrown and no alert fires. A prose match does not break loudly, it breaks months later in somebody else's repository.

The mirror-image mistake is putting your diagnosis in instead: a stack trace, a query fragment, an internal hostname. None of it tells the caller what to do next, and all of it tells a stranger how you are built.

Codes are versioned by addition. A new code breaks nobody; renaming one, or pointing an existing code at a situation it did not previously cover, breaks every caller who matched on it, and breaks them silently. Retire codes in the documentation rather than deleting them.

The retry fields are fragile in a different direction. A wait time that is always the same number, or always zero, is worse than no wait time, because callers build backoff on top of it and you have taught them to hammer you. And if every failure comes back retryable, the flag has stopped carrying information, so callers go back to guessing from the status code, which is where you started.

The way to have full detail and a safe response at once is an identifier. Keep the stack trace, the query and the host name in your own logs, return a short request identifier that appears in both, and ask the caller to quote it. They get a one-step route to an answer and a stranger probing the service learns nothing. Add a link to the documentation for that specific code, not to the general error page: it puts a caller who meets an unfamiliar code at two in the morning one click from the explanation, and it forces somebody to write the explanation.

FIG 3What callers actually used, from one interface's support queue
The sentence that absorbs most of the design effort is the least used part, and the code that almost nobody designs carefully is the most used.

Those four numbers add to 100, and the 52 on the code is larger than the other three put together, since 23 + 16 + 9 = 48. The sentence that gets rewritten four times in review is read by a human in 9 per cent of cases; the string that gets typed once and never looked at again is what caller code depends on in 52. Spend the effort in that proportion, and treat every addition to the code list as the only change here that is unconditionally safe.

Worth keeping

  • A caller's program reads a failure first and a person reads it hours later, so the stable code matters more than the readable sentence.
  • Whether retrying could help cannot be inferred from the status code, because a capacity problem and a permanent refusal can share one, and only the service knows which it is.
  • Adding a new error code breaks nobody, while renaming one or reusing it for a new situation breaks every caller who branched on it, without any error being raised.

Where this goes next

This question is one step of Designing an Interface to Call, a course that works through its subject from the beginning. The page that teaches this part of it is Saying What Went Wrong, and it reads the whole way through whether or not you have an account.

What an account changes is the practice: problems standing under every idea, marked with a reason rather than a tick, in a copy of the course that is yours.

Read that pageThe whole course

Other questions people ask about this

  1. What does it mean for an API method to be safe?A safe method is one that changes nothing on the server when it is called, so a caller may repeat it freely and a cache or a crawler may use it without anything being altered.
  2. What's wrong with naming an API endpoint after an action like /getUser?Naming an endpoint after an action bakes one operation into its address, so every new operation needs a new endpoint and the thing being acted on never gets a stable name.
  3. What's the difference between safe and idempotent in HTTP methods?Safe means the call changes nothing; idempotent means repeating the call leaves the same result, even if the first call changed something.
  4. Why do I need an idempotency key if my operation is already idempotent?You need the key because repeating an operation safely still leaves the caller unsure what happened, and the key returns the first answer.
  5. Where should a session ID be stored so JavaScript can't read it?Store it in an HttpOnly cookie set by the server; that is the only browser storage JavaScript running on the page cannot read.
  6. Does signing out actually invalidate my token on the server?No. Signing out clears your browser's copy of the token, but the server keeps accepting that token until it expires.

Read alongside