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.
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:
| Meaning | Example | |
|---|---|---|
400 | The bytes are wrong | Truncated JSON, a header that won’t parse |
415 | The bytes are fine, the type is wrong | You sent XML, the endpoint takes JSON |
422 | The bytes and type are fine, the instructions aren’t | Valid 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 change | Method preserved | |
|---|---|---|
| Temporary | 302 | 307 |
| Permanent | 301 | 308 |
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.
Test Your Understanding
Quick architectural scenario challenge based on this breakdown.
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 –
Sign in to share your thoughts
Nothing yet. Be the first.