Lesson 02 / 16
Status Codes
The status line's first digit is an instruction: the intermediary draws its caching, routing and retry decisions from it without ever opening the body.
Contents
The previous lesson measured the request’s fields: method, path, host. The response’s header fields were looked at, but its first line was deliberately left out. The first decision the intermediary makes about a response sits on that line, and what the line carries is a three-digit number.
This lesson’s question: how far can the intermediary get, without ever opening the response’s body, just by looking at that number’s first digit. Does an intermediary that also opens the body reach the same decisions — and if so, what does the extra byte it reads buy?
The Status Line
A response opens with a status line. The dump below is for teaching purposes and is not run:
HTTP/1.1 200 OK Content-Type: application/json Content-Length: 128 HTTP/1.1 301 Moved Permanently Location: /olcum/kuzey/guncel HTTP/1.1 503 Service Unavailable Retry-After: 30
The line has three parts: the protocol name and version, the three-digit status code, and a short phrase written for a human to read. The only part useful to the intermediary is the number in the middle; the text on the right is informational and does not enter any decision.
Even the number itself is not needed whole. The first digit gives the code’s status class, and that is the actual thing the intermediary reads.
The Class Itself Is an Instruction
| Class | Meaning | What it tells the intermediary |
|---|---|---|
| 1xx | Informational | Interim response; forward it, do not cache it |
| 2xx | Success | The response is fit to be cached |
| 3xx | Redirection | The resource’s address changed |
| 4xx | Client error | The same request gives the same result; retrying is pointless |
| 5xx | Server error | If the method is idempotent, the request can be retried |
The critical property of this table is that the right column uses the class, not the code itself. The intermediary does not need to know the difference between 404 and 400; both are in the fourth class, and in both, retrying is pointless. This is the highest level of detail a layer that does not know the application can know, and the protocol placed exactly that level in the first digit.
The first class does not enter the model’s measurement set, for a reason: 1xx is an interim response, it does not close the exchange. The intermediary neither caches it nor builds a decision on it; it forwards it as is and keeps waiting for the final response. Since what is measured is the decision taken per exchange, a response that does not close the exchange cannot enter the count.
The resource design topic of the Web API Design course measures which code should be chosen: it counts how a loose mapping raises the number of requests. Here the choice is a given; what is measured is how many decisions a given class lets the intermediary make.
Three Decisions
The previous lesson’s five decisions stay in place. The status line opens three more:
| Decision | What determines the truth |
|---|---|
storeworthy |
Is the response actually successful |
relocated |
Did the resource actually move to another address |
retryworthy |
Did it fail on the server side, and is the method idempotent |
The oracle’s truth reads these from the response’s actual class. The code the server declares is a separate field, and the two do not always agree: in the setup, a few server errors are declared as 200 — this lesson’s form of the reading the course keeps returning to: nothing verifies what is written in the message.
The decision set consists of forty exchanges and eight decisions — three hundred and twenty decisions. The smallest measurable difference is ; no smaller difference is claimed.
Assumptions of the measurement:
- HA7 — The previous lesson’s forty exchanges and five decisions are taken as is; a status code is assigned on top with a separate generator, and the truth of the first five decisions does not change.
- HA8 — Status codes are drawn from a fixed list and do not enter the first class: an interim response does not close the exchange, and an exchange that does not close cannot enter the count.
- HA9 — The oracle’s truth is the response’s actual class; the code the server declares is a separate field, and in the setup some server errors are declared as 200. There is no mechanism that verifies the declaration.
- HA10 — Two regimes are compared: the intermediary either reads only the status line, or also opens the body. The only difference between them is where the class is read from — the code’s first digit, or the body.
- HA11 — The body is a fixed-shape field representing resolved response content and carries the actual class correctly; the byte cost of opening the body comes from counting this field.
- HA12 — The round-trip balance is a count, not a timing measurement: the round trips added by the third class and removed by the fourth class are computed from the number of exchanges.
Extending the Core
The core is the same as the previous lesson’s; on top of it an assign_status function,
three new decisions and a second visibility tuple that also sees the body are added. The
first five lines of the oracle function are untouched.
SEED = 20260809 SAFE = {"GET", "HEAD"} IDEMPOTENT = {"GET", "HEAD", "PUT", "DELETE"} METHOD_POOL = ["GET", "GET", "GET", "HEAD", "POST", "PUT", "DELETE"] PATH_POOL = ["/olcum/kuzey", "/olcum/yamac", "/ozet", "/kayit", "/oturum"] STATUS_POOL = [200, 200, 200, 201, 204, 301, 400, 404, 500, 503, 503] BASE = ("storable", "shareable", "fresh", "repeatable", "redirectable") STATUS_DECISIONS = ("storeworthy", "relocated", "retryworthy") DECISIONS = BASE + STATUS_DECISIONS OPEN = ("method", "path", "host", "private_marker", "validator") LINE = OPEN + ("status",) LINE_BODY = LINE + ("body",) def generator(seed: int): d = seed % 2147483646 + 1 def r(n: int) -> int: nonlocal d d = (d * 48271) % 2147483647 return d % n return r def exchanges(count: int = 40, seed: int = SEED) -> list[dict]: r, out = generator(seed), [] for i in range(count): method, path = METHOD_POOL[r(7)], PATH_POOL[r(5)] private = path == "/oturum" or r(5) == 0 stale = r(3) == 0 out.append({ "no": i + 1, "method": method, "path": path, "private": private, "stale": stale, "private_marker": private and r(4) != 0, "tag_missed": stale and r(6) == 0, }) return out def assign_status(items: list[dict], seed: int = SEED + 101) -> list[dict]: r = generator(seed) for a in items: code = STATUS_POOL[r(11)] real = code // 100 misreported = real == 5 and r(3) == 0 # server declared the error as 200 a["actual_class"] = real a["status"] = 200 if misreported else code a["misreported"] = misreported return items def oracle(a: dict) -> dict: return { "storable": a["method"] in SAFE and not a["private"], "shareable": not a["private"], "fresh": not a["stale"], "repeatable": a["method"] in IDEMPOTENT, "redirectable": True, "storeworthy": a["actual_class"] == 2, "relocated": a["actual_class"] == 3, "retryworthy": a["actual_class"] == 5 and a["method"] in IDEMPOTENT, } def message(a: dict, visible: tuple) -> dict: full = {"method": a["method"], "path": a["path"], "host": "station.example", "private_marker": a["private_marker"], "validator": a["stale"] and not a["tag_missed"], "status": a["status"], "body": {"class": a["actual_class"], "path": a["path"], "detail": "resolved response body", "trace": f"trc-{a['no']:04d}"}} return {k: v for k, v in full.items() if k in visible} def byte_count(i: dict) -> int: return sum(len(k) + len(str(v)) + 4 for k, v in i.items()) def infer(i: dict) -> dict: k = {} if "method" in i: k["repeatable"] = i["method"] in IDEMPOTENT if "private_marker" in i: k["shareable"] = not i["private_marker"] if "method" in i: k["storable"] = i["method"] in SAFE and not i["private_marker"] if "validator" in i: k["fresh"] = not i["validator"] if "host" in i: k["redirectable"] = True if "status" in i: cls = i["body"]["class"] if "body" in i else i["status"] // 100 k["storeworthy"] = cls == 2 k["relocated"] = cls == 3 if "method" in i: k["retryworthy"] = cls == 5 and i["method"] in IDEMPOTENT return k def measure(batch: list[dict], visible: tuple, decisions: tuple = DECISIONS) -> tuple: d = y = e = b = 0 for x in batch: truth, given = oracle(x), infer(message(x, visible)) for decision in decisions: if decision not in given: e += 1 elif given[decision] == truth[decision]: d += 1 else: y += 1 b += byte_count(message(x, visible)) return d, y, e, b def distribution(batch: list[dict], field: str) -> dict: d: dict = {} for x in batch: d[x[field]] = d.get(x[field], 0) + 1 return dict(sorted(d.items()))
The single line inside infer carries the whole distinction: if the body is visible, the
class is read from the body; if not, from status // 100. That single line is the only
difference between the two intermediaries.
Two Intermediaries
batch = assign_status(exchanges()) print("actual class distribution:", distribution(batch, "actual_class")) print("declared code distribution:", distribution(batch, "status")) print("responses misreporting their class:", sum(x["misreported"] for x in batch)) t = measure(batch, LINE, BASE) print(f"the base five decisions (same in both regimes): correct {t[0]}, wrong {t[1]}") print() print(f"{'regime':<14s} {'correct':>7s} {'wrong':>7s} {'unavailable':>12s} {'bytes':>6s}") for name, g in (("line", LINE), ("line+body", LINE_BODY)): d, y, e, b = measure(batch, g) print(f"{name:<14s} {d:7d} {y:7d} {e:12d} {b:6d}")
actual class distribution: {2: 22, 3: 3, 4: 7, 5: 8}
declared code distribution: {200: 16, 201: 5, 204: 4, 301: 3, 400: 4, 404: 3, 500: 1, 503: 4}
responses misreporting their class: 3
the base five decisions (same in both regimes): correct 195, wrong 5
regime correct wrong unavailable bytes
line 309 11 0 4244
line+body 315 5 0 8124
The first column to read is unavailable, zero in both regimes. As long as the status line is visible, the intermediary gets all three new decisions; it does not ask the server and does not owe a round trip. The previous lesson’s five decisions also stay in place: 195 correct, 5 wrong in both regimes.
The whole difference sits in six of the three hundred and twenty decisions. The line regime makes 309 correct decisions, the regime that also opens the body makes 315. Against that, opening the body raises the byte count from 4244 to 8124.
What the Body Buys
Where the six decisions come from is worth a closer look.
print(f"{'':<20s} {'line':>14s} {'line+body':>16s}") print(f"{'decision':<20s} {'correct':>7s} {'wrong':>7s} {'correct':>8s} {'wrong':>7s}") for decision in STATUS_DECISIONS: s = [0, 0, 0, 0] for x in batch: truth = oracle(x) for j, g in enumerate((LINE, LINE_BODY)): v = infer(message(x, g)) s[2 * j + (0 if v[decision] == truth[decision] else 1)] += 1 print(f"{decision:<20s} {s[0]:7d} {s[1]:7d} {s[2]:8d} {s[3]:7d}") print() print(f"{'no':>3s} {'method':<7s} {'path':<14s} {'truth':>7s} {'declared':>9s} " f"{'idempotent':>10s}") for x in batch: if x["misreported"]: print(f"{x['no']:3d} {x['method']:<7s} {x['path']:<14s} {x['actual_class']:7d} " f"{x['status']:9d} {str(x['method'] in IDEMPOTENT):>10s}") ml, mb = measure(batch, LINE), measure(batch, LINE_BODY) gain, cost = mb[0] - ml[0], mb[3] - ml[3] print() print(f"decisions gained by the body {gain}, bytes it costs {cost}, " f"{cost / gain:.1f} bytes per decision") print("exchanges where the two regimes' decision sets diverge:", sum(1 for x in batch if infer(message(x, LINE)) != infer(message(x, LINE_BODY)))) added = sum(x["actual_class"] == 3 for x in batch) avoided = sum(x["actual_class"] == 4 for x in batch) print(f"round trips added by redirection {added}, round trips avoided by not " f"retrying pointlessly {avoided}, net over forty exchanges {added - avoided}")
line line+body decision correct wrong correct wrong storeworthy 37 3 40 0 relocated 40 0 40 0 retryworthy 37 3 40 0 no method path truth declared idempotent 2 PUT /olcum/yamac 5 200 True 8 GET /olcum/kuzey 5 200 True 9 GET /ozet 5 200 True decisions gained by the body 6, bytes it costs 3880, 646.7 bytes per decision exchanges where the two regimes' decision sets diverge: 3 round trips added by redirection 3, round trips avoided by not retrying pointlessly 7, net over forty exchanges -4
In thirty-seven of the forty exchanges the two intermediaries produce the same decision set; opening the body changes not a single decision in these thirty-seven, only reading bytes. In the three exchanges where they diverge, six decisions are gained at a cost of 3880 bytes — 646.7 bytes per decision.
This is also where the point of comparison appears. The status line hands the intermediary three decisions at once in all forty exchanges, costing only thirteen bytes per exchange. The body corrects six of three hundred and twenty decisions, costing ninety-seven extra bytes per exchange. Placing the class in the first digit is not a choice about saving space; it is a choice to put the smallest piece of data the intermediary needs at the very front of the response.
A Decision Is Free, Acting on It Is a Round Trip
The relocated decision is correct in forty of forty decisions in both regimes; it
is this lesson’s cheapest decision. Being cheap does not mean the decision’s
consequence is cheap too.
A third-class response does not deliver the content requested, it only reports another address. The intermediary reads this from the message and asks no one — the decision itself needs no extra round trip. But carrying out what the decision calls for, going to the new address, is a full round trip. Three of the forty exchanges are third class, and these three add three round trips no header field could have absorbed; the only way to remove that round trip is to change the address.
The fourth class works in the opposite direction. When the intermediary sees a 4xx, it knows resending the same request gives the same result, and does not send it. Seven of the forty exchanges are fourth class; in these seven, a single digit saves one round trip in advance that would not have changed the outcome anyway. The balance over forty exchanges is minus four: redirection adds three round trips, client error removes seven.
The fifth class’s balance depends on the method and so cannot be written on its own. What determines how many of the eight exchanges can be retried is not the status class but the method field measured in the previous lesson; the decision sits at the intersection of two fields.
These three lines show why the measure is round trips, not the number of fields. The status line is a single field, and without touching the body at all it adds three round trips and removes seven, over forty exchanges.
The Response That Misreports Its Class
In three exchanges the server reported a server-side error as 200. The actual state is written in the body, not in the line.
In the line regime these three responses break two decisions at once. storeworthy
comes out wrong in all three: the intermediary puts a failed response into its cache
and distributes the same error to later requests too. retryworthy also comes out
wrong in all three, because all three use an idempotent method and, had the real
class been known, the request could have been resent. relocated is not broken: a
wrong declaration does not touch the third class.
The real conclusion: the intermediary did not make a wrong decision; it responded correctly to a wrong declaration. Reading the first digit comes bundled with the assumption that the first digit was written correctly. No mechanism in the protocol tests this assumption, and opening the body is not a fix either, since the body is written by the same server.
There is a narrowing side here too. When the server writes the status line correctly, the intermediary loses nothing; when it writes it wrong, closing the gap with the body costs ninety-seven bytes per exchange, and in thirty-seven of the forty exchanges that byte cost is spent for nothing. Reporting the class correctly is the single thing that minimizes the data the intermediary has to read.
Summary
- The status line’s first digit gives a status class; the intermediary draws its caching, routing and retry decisions from this single digit, not from the whole code.
- Class-based reading does not require the intermediary to know the code’s detail: 400 and 404 carry the same instruction.
- On the set of forty exchanges and eight decisions, the line regime gives 309 correct, 11 wrong decisions and 4244 bytes; the regime that also opens the body gives 315 correct, 5 wrong decisions and 8124 bytes.
- The two regimes produce the same decision set in thirty-seven of the forty exchanges; the six decisions the body buys cost 646.7 bytes each.
- A cheap decision does not mean a cheap consequence: redirection adds three round trips over forty exchanges, client error removes seven, and the balance is minus four.
- The three responses that misreport their class break the
storeworthyandretryworthydecisions; the intermediary does not make a wrong decision, it responds correctly to a wrong declaration.
Next Step
The status line says what the response is. There is something it does not say: the same address can have more than one counterpart, and the server does not report in the status line which one it sent. The same measurement record can be served in separate formats, separate languages, separate encodings; either one comes back with a 200. The next lesson measures the place where this choice is made — the header fields — and asks two questions at once: does adding irrelevant fields buy the intermediary any decisions, and if the field the choice was based on is not reported, which copy does the intermediary give to whom.
To keep your progress and take notes, Log in
My notes
Log in to take notes.