Topic 07

Status Codes as a Contract

HTTP

A status code is an instruction to the client about what to do next: show the result, fix the request, sign in, wait and try again, or give up. It is read by the buyer's browser, by the scanner app's HTTP library, by the load balancer deciding whether api-01 is healthy, and by the dashboard counting errors, and every one of them acts on the first digit before anyone reads the body. A service that returns 200 with {"error": "seat taken"} has told all of them that the hold succeeded.

The dozen or so codes a service actually uses are few enough to know by heart, and each has exactly one job. This topic is that list, the decision each one encodes, and the night the scanner app let people through the door on a cached 200 because the service had used the wrong one.

The Five Classes as Instructions

The first digit is the contract; the code refines it. 2xx says "here is the result." 3xx says "look elsewhere." 4xx says "you sent something wrong; fix it before sending it again." 5xx says "we failed; sending it again may work." A client library's retry policy reads that digit: it retries 5xx and never 4xx, because a 4xx retried unchanged will fail the same way a thousand times. The load balancer reads it too: enough 5xx from one instance and the instance is pulled from rotation. A service that sends a 500 for a buyer's typo has just told the balancer that api-02 is sick.

What each class tells the client to do
2xx · the work was doneShow the result; do not send it again
3xx · the answer is elsewhereFollow the Location header
4xx · the request is wrongChange something before retrying
5xx · the service failedRetry with backoff; the request may be fine
429 or 503 with Retry-AfterWait exactly that long, then retry once

Pick the class first, then the code, then the body. The class decides whether a retry is allowed; the code decides what kind of fix; the body says why. Most bugs in this area are a class error dressed as a code error: the 500 that should have been a 422, the 200 that should have been a 409. Get the digit right and a client that knows nothing else about Stagedoor already behaves correctly.

The Success Codes That Carry Meaning

200 carries a body: the seat map, the order, the list of events. 201 says a resource was created and carries a Location header pointing at it, which is how a client that sent POST /orders learns the URL of the order it made. 202 says the request was accepted and the work has not happened yet: the refund is queued, the PDF is rendering, and the body carries a URL to poll. 204 says the work is done and there is nothing to say, which is the right answer to a DELETE and to a PUT whose result the client already knows.

The one that gets misused is 201. A 201 for a hold that was actually queued for a worker is a lie the client acts on: it follows the Location, finds nothing there yet, and shows the buyer an error for a hold that will exist in 200 milliseconds. Chapter 8 returns 202 for everything that leaves the request, with a status resource the client can watch. The code is a promise about the state of the world at the moment the response is written, not about the state it will be in shortly.

The Client Errors Worth Distinguishing

400 is a body that does not parse: the bytes are not JSON, the form is malformed. 422 is a body that parses and fails validation: seat_id is a string, the quantity is negative, the date is in the past. Chapter 3 draws that line at the boundary, and the distinction lets a client tell "my serializer is broken" from "my user typed something wrong." 401 says the request carried no usable credential; 403 says the credential was fine and the answer is still no. 404 says the resource is absent, or is being hidden, and Chapter 3 makes hiding a deliberate choice rather than an accident.

Two more do most of the work at on-sale. 409 Conflict is the seat that is already held: the request was well-formed and permitted and cannot be done because of the current state, so the client should try a different seat, not the same request. 429 Too Many Requests, with a Retry-After header, is the scanner app scanning 40 codes a second from one gate; Chapter 14 sets the limit and the header tells the app how many seconds to wait. Without the header the app retries at once and the limiter's load doubles.

The Server Errors and What They Signal

500 is an unhandled exception: a bug, always, and the alert should fire on the first one. 502 and 504 come from the load balancer when the instance did not answer at all or did not answer in time; the service never sends them from its own code. 503 with Retry-After is the service saying it is alive and refusing on purpose, because it is shedding load or draining before a restart, which Chapter 11 does inside the 30-second termination grace. The number in the header is a real promise about when to come back.

A 500 from a validation bug and a 503 from a full connection pool tell the client opposite things. The 500 will fail again on retry because the code is wrong; the 503 will succeed on retry because the pool will have freed. A service that returns 500 for both has hidden the only bit the client needed. The rule Stagedoor follows: 502, 503 and 504 come only from the edge or from the drain path, so a 5xx that originates in a handler is always a real bug and the on-call knows it without reading the trace.

The Scanner Bug

The scanner app at the door reads GET /tickets/{code} for every ticket presented. The first version of the endpoint returned 200 for every code it received, with {"valid": true} or {"valid": false} in the body. The scanner's HTTP library treated 200 as success, which is what 200 means, and the network cache on the venue's Wi-Fi stored the responses, which is what a cache may do with a 200 to a GET that says nothing about caching. A forged code produced a cached 200 that looked like every other success. Worse, a valid code that was refunded an hour before the show had a cached 200 with valid: true from the afternoon's test scan, and the door let the buyer in on a ticket that no longer existed.

The scanner bug: one wrong code, three systems misled
200 + valid: falsethe service
Cachedvenue Wi-Fi proxy
Successthe HTTP library
Door opensthe scanner app

The fix was three codes and one header. 200 for a valid ticket, with the seat and the buyer's name in the body. 404 for a code that does not exist. 410 Gone for a code that existed and was revoked by a refund, so the door staff see "refunded" rather than "unknown." And Cache-Control: no-store on all three, because a ticket's validity is not a fact any cache may hold for even a second. The scanner app's library now raises on the 404 and the 410 without a line of its code changing, which is the point: the status code is the part of the response that every generic client already understands.

What Belongs in the Body

The code says what to do; the body says why. A 409 on POST /holds tells the client that the seat is held and to try another, and a bare 409 is all a generic client needs. A client written for Stagedoor wants more, and Chapter 3 gives every error the same shape, a Problem Details document with a type, a title and a detail, so that one parser handles every failure the service can produce.

A 409 that a client can act on
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
Cache-Control: no-store

{
  "type": "https://stagedoor.example/problems/seat-held",
  "title": "Seat is held by another buyer",
  "seat": "14C",
  "hold_expires_at": "2026-09-18T19:46:10Z"
}

The response is a conflict for seat 14C carrying the moment the other buyer's hold expires. The code alone tells any client to try a different seat. The body tells Stagedoor's own web page enough to show "available again in 6 minutes" with a countdown instead of the word "error," and the type URL is a stable name for this failure that a client can switch on without parsing English. The status decided the behaviour; the body improved it.

401 vs 403

401 Unauthorized means the request carried no usable credential: the token is missing, expired or unparseable. The fix is to authenticate, and the response says how in a WWW-Authenticate header. Use it when a valid login would change the answer.

403 Forbidden means the credential was fine and the answer is still no: this organizer may not read that organizer's sales. Authenticating again will not help, and the client should not try. Returning 401 for a permission failure sends the client into a login loop; returning 403 for a missing token confirms that the resource exists to someone who has not signed in. Chapter 5 decides which to send, per resource, and when a 404 is the more honest of the three.

Common Mistakes
  • 200 with an error in the body — every generic client library, cache and dashboard reads success; the scanner bug is this mistake, and the door opened on it.
  • 500 for a client's bad input — the client's retry policy repeats the request forever, the load balancer counts the instance as failing, and the error-rate alert fires on a buyer's typo.
  • 404 for everything missing, including "you may not see it" — the code can be right, but the choice between 404, 403 and 401 is a decision about what the response reveals, and making it by accident is different from making it by design.
  • 201 for a job that was only accepted — the client follows the Location to a resource that does not exist yet and shows an error for work that will finish in a second; 202 with a status URL is the honest answer.
  • Omitting Retry-After on 429 and 503 — the client retries immediately, and the load that caused the code doubles at the moment the service could least afford it.
  • Sending 502 or 503 from application code — the on-call can no longer tell a real bug from the edge failing to reach an instance, and the alert that should page on every 500 has to be tuned down.
Best Practices
  • Pick the class first (retry or not), then the code, then the body, and never let the body contradict the code.
  • Use 409 for state conflicts (held, already paid, already refunded) and 422 for shape failures, so a client can tell "fix your input" from "try a different seat."
  • Send Retry-After on every 429 and 503, and make the number true by deriving it from the limiter's window or the drain's remaining grace.
  • Return 502, 503 and 504 only from the edge or the drain path, so that a 5xx from a handler is always a bug worth paging on.
  • Mark every response whose truth can change, a ticket's validity, a seat's state, with Cache-Control: no-store, so no proxy between the service and the client can replay it.
Comparable toolsRFC 9110 the source for every code and its retry semanticsFastAPI HTTPException, Spring ResponseStatusException, Express res.status: the framework helpersRFC 9457 Problem Details, the body shape Chapter 3 adoptsStripe a documented code table as the model of a public contract

Knowledge Check

A generic client library receives a 503 with Retry-After: 4 from one request and a 422 from another. What should it do with each?

  • Retry both with backoff, since either one may succeed once the service has recovered from its load
  • Wait four seconds and resend the first; report the second to the caller without ever retrying it
  • Give up on both, since a 5xx means the service is down and a 4xx means the request was rejected
  • Retry the second with backoff and treat the first as fatal, since a 503 signals the service is gone

What does a 202 promise that a 201 does not?

  • That the work has not been done yet, only accepted, and a status URL is where to watch it
  • That the request was recorded with an idempotency key and a repeat will return the same body
  • That a Location header points at the created resource and the client may fetch it immediately
  • That the client may safely resend the request if the response is lost, because nothing was committed

The scanner endpoint returned 200 with valid: false for a forged ticket. Which consequence made this worse than a slow bug?

  • The scanner's HTTP library kept retrying the request, because a 200 with a false flag reads as an incomplete result
  • The load balancer pulled the instance from rotation, because a run of 200s with error bodies trips the health check
  • The error dashboard filled with 200s marked as failures, so the real on-sale errors were lost among the noise
  • A proxy cache on the venue network stored the 200 and replayed it that night for a ticket that had since been refunded

An organizer's token is valid, but she requests another organizer's sales report. The service answers 401. What goes wrong on the client?

  • The client retries the request with backoff, treating the 401 as a transient failure of the token-issuing service
  • The client sends her back to the login page, she signs in successfully, and the same request fails again
  • The client learns that the other organizer's report exists, which is the leak a 404 would have prevented
  • The client's library raises a permission error and the page shows the right "not allowed" message

You got correct