OwlTheEngineer
Engineering MistakesEpisode 17 · Backend

Status codes: the first digit is the whole contract

Every request came back 200 and nobody could buy anything. The last two digits have no categorisation role at all — and an error inside a success is a lie three separate systems believe.

Owl · 8 min read · 0 views
– comments
Ep 17 · Backend
60s VISUAL BREAKDOWN
Episode 17
Watch the fast visual breakdown or discuss on social:

The dashboard was green all afternoon. Every request came back 200, the error rate was flat, and nobody could buy anything. The checkout endpoint had been answering 200 OK with {"error": "card declined"} inside it since the deploy, and three separate systems believed it: the cache stored it, the client never retried it, and the monitoring that counts non-2xx responses counted nothing at all.

This is the written half of a ninety-second video. The video had room for one rule and one failure. The post owes you the constructive half — what to send instead, why the wrong thing became so common, and the four arguments a team actually has about status codes once it agrees not to lie in the first digit.

The rule, in one sentence from the spec

RFC 9110 §15 is unusually direct: “The first digit of the status code defines the class of response. The last two digits do not have any categorization role.”

That is stronger than it first reads. The three digits are not a hierarchy where 404 is a specialised 400; the 04 carries no structural meaning whatsoever. It is a lookup key into a registry, and the contract — the part a client is obliged to honour — lives entirely in the leading digit. The spec spells out the obligation: “a client MUST understand the class of any status code, as indicated by the first digit, and treat an unrecognized status code as being equivalent to the x00 status code of that class,” and gives its own worked example: a client that receives 471 “can see from the first digit that there was something wrong with its request and treat the response as if it had received a 400 (Bad Request) status code.”

So a code you have never seen is still actionable, and a code outside the range is not: “Values outside the range 100..599 are invalid,” and a client “SHOULD process the response as if it had received a 5xx (Server Error) status code.”

The reason phrase carries nothing. “OK”, “Not Found” and “I’m a teapot” are decoration — §15.1 says the phrases “are only recommendations — they can be replaced by local equivalents or left out altogether without affecting the protocol.” The number is the API. Parsing the phrase is parsing a comment.

Why 200 {"error": …} happens, honestly

It is tempting to file this under carelessness. It usually isn’t, and being fair about the cause makes it easier to fix.

The dominant reason is that for years the client was punished for correctness. A lot of HTTP wrappers reject the promise, throw, or jump to an error branch on any non-2xx response, which means a 422 for “that email is already taken” arrives in the same catch block as a DNS failure. Once a team has been bitten by a validation message that vanished into a generic “something went wrong” toast, sending 200 and putting the real outcome in the body starts to look like the defensive choice.

The platform itself is split on this, which is why the habit persists. MDN, on fetch(): “A fetch() promise only rejects when the request fails, for example, because of a badly-formed request URL or a network error. A fetch() promise does not reject if the server responds with HTTP status codes that indicate errors (404, 504, etc.). Instead, a then() handler must check the Response.ok and/or Response.status properties.” The browser’s own API treats a 500 as a successful fetch of an unsuccessful response — which is exactly right, and exactly the opposite of what many popular wrappers do.

The fix is not to lie downwards. It is to make the error body good enough that the client has no incentive to.

What to send instead: RFC 9457

There is a standard shape for an error body, and it is newer than most codebases: RFC 9457, Problem Details for HTTP APIs, July 2023, Standards Track, which obsoletes RFC 7807. If you learned this as 7807, the members are the same and the number has moved.

The media type is application/problem+json, and the object has five defined members:

  • type — “a JSON string containing a URI reference that identifies the problem type”
  • status — “a JSON number indicating the HTTP status code generated by the origin server for this occurrence”
  • title — “a JSON string containing a short, human-readable summary of the problem type”
  • detail — “a JSON string containing a human-readable explanation specific to this occurrence”
  • instance — “a JSON string containing a URI reference that identifies the specific occurrence”

The document’s own example, extension members and all:

{
  "type": "https://example.com/probs/out-of-credit",
  "title": "You do not have enough credit.",
  "detail": "Your current balance is 30, but that costs 50.",
  "instance": "/account/12345/msgs/abc",
  "balance": 30,
  "accounts": ["/account/12345", "/account/67890"]
}

Two things to notice. type is a URI, so the problem type is a thing a client can switch on and a human can open — stable and machine-readable in the same field, which is what the old {"code": 4102} was reaching for and missing. And balance and accounts are not in the spec: extension members are allowed, which is where the field-level validation detail your client actually needs belongs.

The honest caveat is that status is duplicated inside a body whose transport already carries it. The RFC is aware of that, and the header wins — the body’s copy is advisory, useful when the payload is logged or forwarded away from its response. Do not let the two disagree.

400 or 422 — settled by a section number

This is the argument the topic actually starts, and it now has a cleaner answer than most teams think, because 422 no longer needs a second specification.

The widespread belief is that 422 Unprocessable Entity belongs to WebDAV (RFC 4918) and that reaching for it means importing a spec about remote file authoring. That was true and is out of date. RFC 9110 §15.5.21 defines 422 Unprocessable Content in core HTTP semantics: the status code “indicates that the server understands the content type of the request content (hence a 415 (Unsupported Media Type) status code is inappropriate), and the syntax of the request content is correct, but it was unable to process the contained instructions.”

Line that up against 400, §15.5.1: “the server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).”

The split is clean once you read them together, and it is about where the request failed:

MeaningExample
400The bytes are wrongTruncated JSON, a header that won’t parse
415The bytes are fine, the type is wrongYou sent XML, the endpoint takes JSON
422The bytes and type are fine, the instructions aren’tValid JSON, "quantity": -3

A validation failure on well-formed JSON is a 422. 400 is a parser complaint. Both are 4xx, so every intermediary treats them identically — which is the point made earlier: choosing between them is a courtesy to the reader of your API, not a change in protocol behaviour. Worth getting right, not worth a long meeting.

404, 410, and an absence you can cache

404 does not mean “this never existed”. §15.5.5: the origin server “did not find a current representation for the target resource or is not willing to disclose that one exists.” That second clause is why 404 is a security answer as often as a correctness one — returning 403 on a resource the caller may not see confirms it exists, and 404 refuses to.

When the absence is known to be permanent, §15.5.11 offers a better code: 410 (Gone) “indicates that access to the target resource is no longer available at the origin server and that this condition is likely to be permanent.” It is a stronger claim than 404 and, unlike 404, it tells a crawler to stop asking.

And the fact that closes this episode and opens the next two: 404 is heuristically cacheable. §15.1 lists the reusable-by-default codes as “200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, and 501 in this specification.” A 404 served for a product that had not been created yet can be stored and served back after the product exists. That is not a bug in the cache; it is the documented default, and the only thing standing between you and it is an explicit cache directive. Episodes 19 and 20 are about exactly that gap.

301, 302, 307, 308 — one line of history

The four redirects look redundant and are not. 302 Found has a long, unhappy history of clients rewriting a POST into a GET when they followed it — widespread behaviour that contradicted the original text, and which the spec eventually documented rather than pretended away.

307 and 308 exist to remove the ambiguity by saying the quiet part in the definition. §15.4.8, on 307 (Temporary Redirect): the target “resides temporarily under a different URI and the user agent MUST NOT change the request method if it was not GET or HEAD.” 308 (Permanent Redirect) is the same guarantee for a permanent move.

So the grid is two questions, not four codes: is the move permanent, and may the method change?

Method may changeMethod preserved
Temporary302307
Permanent301308

If you are redirecting a POST and you care that it stays a POST, 307 and 308 are the only two that promise it. And note which of these are on the cacheable list above: 301 and 308. A permanent redirect you did not mean is a permanent redirect the whole internet may keep.

The short version

Send the class you mean. The first digit is read by caches, retry logic and every dashboard you own, and it is the one part of the response they all agree on. Put the detail in a application/problem+json body where a human and a client can both use it — and if your client library punishes you for a correct 4xx, fix the client library. It is one wrapper function. The alternative is a green dashboard over a checkout nobody can complete.

SYSTEM DESIGN CHECK

Test Your Understanding

Quick architectural scenario challenge based on this breakdown.

SCENARIO

A 3rd-party payment gateway starts timing out with 504 errors during peak traffic.

Q1.How should a downstream dependency failure be handled to protect upstream service availability?

Comments –