---
title: 'Status Codes'
source: 'https://academia.sh/en/courses/application-protocols/status-codes'
course: 'Application Layer Protocols'
language: en
updated: '2026-08-17T18:07:00+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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:

```text
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.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.

```python
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

```python
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.

```python
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.
