Skip to content
academia.sh

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 1/320=0.00311/320 = 0.0031; 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 storeworthy and retryworthy decisions; 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.

Start typing to search.

↑↓ Esc navigate · open · close