HTTP Response Codes
A Scout Loop around HTTP, Apis, Backend Systems, Resilient Clients: build evidence, then choose the next action.
Did this fit you?
What It Is
An HTTP status code is the three-digit number on the first line of every response. The first digit alone tells you which of five classes a response belongs to: 1xx informational, 2xx success, 3xx redirection, 4xx client error, 5xx server error. RFC 9110 makes that grouping load-bearing — a client that receives a code it doesn't recognize is allowed to treat it as the generic code for its class, so an unknown 4xx is handled like 400 and an unknown 2xx like 200. That single rule is what lets you write response handling that switches on the first digit instead of enumerating every code.
In this loop you won't memorize a table. You'll touch real status lines and headers with curl against a live echo service, watch a redirect quietly change an HTTP method, and write a small Python function that classifies any response by its first digit and backs off correctly on 429 and 503. The concrete artifacts you'll end with are a handful of annotated curl transcripts and a runnable status-class router with retry logic.
Why It Matters
When your code calls model endpoints, vendor APIs, and your own backend services, status codes are the control signals your retry, caching, and error-surfacing logic runs on. Getting the class boundaries right is the difference between a client that recovers and one that amplifies an outage. A 4xx means the request itself is wrong — retrying sends the same broken request again. A 5xx or a 429 is often transient and retryable, but only if the request is idempotent; replaying a non-idempotent POST on a 503 can double-charge or double-write.
Two specifics pay off immediately. First, 429 Too Many Requests and 503 Service Unavailable can carry a Retry-After header telling you exactly how long to wait — honoring it beats a fixed sleep and beats hammering a rate limiter. Second, the codes people reach for are often slightly wrong: 401 vs 403, 404 vs 410, and 400 vs 422 each encode a distinct fact for the caller. Picking the right one makes your own APIs debuggable by the next person who calls them.
Guided First Play
You'll go from reading raw status lines to a working classifier in one sitting. By the end you'll have seen a redirect change a method out from under you and have a Python function that decides whether and when to retry — the seed of a resilient client.
Get a workspace
Everything here uses curl and the Python standard library, both already present on macOS and Linux — no installs, no API keys. The live target is the public httpbin echo service, which returns whatever status code you ask it to. Confirm your two tools before probing anything:
curl --version | head -1
python3 --versionBoth print a version line. If curl is missing, install it; if python3 is missing, any Python 3.8+ works.
Read a status line and a class
Use -i to include response headers and -s to silence the progress meter. Ask for a 200, then an unusual code, and notice that the first digit — not the exact number — is what your handler will branch on:
curl -is https://httpbin.org/status/200 | head -1
curl -is https://httpbin.org/status/418 | head -1
# Just the numeric code, for scripting:
curl -o /dev/null -s -w "%{http_code}\n" https://httpbin.org/status/503You see 'HTTP/2 200', 'HTTP/2 418', and a bare '503'. 418 is unassigned for real use but proves the point: you can reason about it as a generic 4xx without knowing what it 'means'.
Make 401 flip to 200 — and watch a redirect change your method
401 Unauthorized means unauthenticated: credentials are missing or invalid, and a correct server signals how to authenticate with a WWW-Authenticate header. (Contrast 403 Forbidden, where the server knows who you are and still refuses.) Hit an endpoint that demands basic auth, then supply it:
# No credentials -> 401 plus a WWW-Authenticate challenge:
curl -is https://httpbin.org/basic-auth/user/passwd | head -5
# Correct credentials -> 200:
curl -is -u user:passwd https://httpbin.org/basic-auth/user/passwd | head -1First call: 'HTTP/2 401' with a 'WWW-Authenticate: Basic realm=...' line. Second call: 'HTTP/2 200'. The 401 told you exactly how to fix the request.
Now the redirect trap. 301/302/303 historically let a client switch the method (a POST becomes a GET when followed); 307 and 308 MUST preserve the original method and body. Send the same POST through a 302 and a 307 and let curl follow each with -L. The target /post only accepts POST, which turns the difference into a visible status change:
# 302: curl downgrades POST -> GET, so /post rejects it with 405:
curl -sL -o /dev/null -w "final: %{http_code}\n" \
-X POST --data k=v \
"https://httpbin.org/redirect-to?status_code=302&url=/post"
# 307: method is preserved, POST reaches /post -> 200:
curl -sL -o /dev/null -w "final: %{http_code}\n" \
-X POST --data k=v \
"https://httpbin.org/redirect-to?status_code=307&url=/post"The 302 path ends in 'final: 405' (method got rewritten to GET); the 307 path ends in 'final: 200'. This exact behavior is a common production bug when a load balancer or framework 'helpfully' redirects POSTs.
Write the classifier and a Retry-After-aware fetch
Now turn the first-digit rule into code. This uses only the standard library, so it runs as-is. classify() maps any code to its class; fetch() retries only on 429 and 503, honors a Retry-After header when present, and otherwise uses exponential backoff. Note what it does NOT do: it never retries a 4xx other than 429, because a malformed or forbidden request won't fix itself on replay.
status_router.py · Path: status_router.py
import time, urllib.request, urllib.error
CLASSES = {1: "informational", 2: "success", 3: "redirect",
4: "client-error", 5: "server-error"}
def classify(code):
return CLASSES.get(code // 100, "unknown")
def fetch(url, retries=3, base=1.0):
for attempt in range(retries + 1):
try:
resp = urllib.request.urlopen(url)
return resp.status, classify(resp.status)
except urllib.error.HTTPError as e:
if e.code in (429, 503) and attempt < retries:
# Honor Retry-After if the server set it; else back off.
wait = float(e.headers.get("Retry-After") or base * 2 ** attempt)
print(f"{e.code} {classify(e.code)} -> waiting {wait}s")
time.sleep(wait)
continue
return e.code, classify(e.code)
if __name__ == "__main__":
for code in (200, 301, 404, 429, 503):
print(code, classify(code))
print("result:", fetch("https://httpbin.org/status/503"))Run 'python3 status_router.py'. You should see the five codes mapped to their classes, then three '503 server-error -> waiting Ns' retry lines with growing waits, and finally 'result: (503, 'server-error')' once retries are exhausted.
Run one focused variation to feel the boundary: change the test URL to /status/404 and re-run. The retry lines vanish — fetch() returns immediately because a 404 is a client error that retrying can't fix. Then try /status/200 and confirm it returns on the first attempt. That contrast is the whole point: the first digit decides the strategy.
# Quick edits to feel the difference (or just change the URL in the file):
sed -i.bak 's#status/503#status/404#' status_router.py && python3 status_router.py
sed -i.bak 's#status/404#status/200#' status_router.py && python3 status_router.py404 run: no 'waiting' lines, result is (404, 'client-error'). 200 run: result is (200, 'success'). The retry path only ever engaged for the retryable classes.
You now have direct evidence of four things that trip up real clients: classes are decided by the first digit, unknown codes fall back to their class, redirects can silently rewrite a method unless they're 307/308, and only some codes are worth retrying — gated by idempotency. That's enough to judge whether the deeper mechanics are worth your time.
Your Next Move
Did reasoning by status class and turning it into retry logic feel like something you'd reach for in your own clients — or like ground you already cover? Use the outcome buttons to record the fit.
Go Deeper would extend status_router.py into a production-grade client: jittered exponential backoff, idempotency keys for safe POST retries, conditional requests with ETags and 304, and the emerging RateLimit-* header fields beyond Retry-After. Project would point the same machinery at a service you actually call — wrapping a model or vendor API with a tested retry-and-backoff layer and a clear map of which codes you surface, swallow, or replay.
Did this fit you?
Sources
This loop is grounded in primary specifications and practitioner references: RFC 9110 for the status-code classes, method safety and idempotency, and redirect semantics; RFC 6585 for 428, 429, 431 and 511; the IANA HTTP Status Code Registry as the authoritative list of assigned codes; and MDN Web Docs for per-code usage guidance, including the confused 4xx pairs and 429's Retry-After behavior. The full links are in the source list.