C.W.K.
Stream
Lesson 07 of 07 · published

Status Code Families — A Polymorphic Protocol, Not a Lookup Table

~12 min · foundations, status-codes, polymorphic, dispatch

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"There are over 60 standard status codes. You'll meet ~20 in practice. But you don't have to memorize them — the first digit tells you which family it belongs to, and that's usually enough to act."

Five Families That Cover Every Response

Every HTTP status code falls into one of five families, identified by the first digit. Each family is a contract with the client — a polymorphic dispatch. A well-written client never crashes on a status code it's never seen, because the family digit alone tells it what to do:

  • 1xx — Informational. "Request received, continuing." Most commonly: 100 Continue (for large uploads), 101 Switching Protocols (WebSocket upgrade), 103 Early Hints (preload hints before the real response).
  • 2xx — Success. "It worked." Common: 200 OK (generic success with body), 201 Created (new resource — Location header points to it), 202 Accepted (async — work queued, not done), 204 No Content (success, no body), 206 Partial Content (range request).
  • 3xx — Redirection. "Look elsewhere." Common: 301 Moved Permanently (update bookmarks), 302 Found (temporary), 304 Not Modified (cache valid, no body), 307 Temporary Redirect (method preserved), 308 Permanent Redirect (method preserved, permanent).
  • 4xx — Client Error. "You broke something." Common: 400 Bad Request (malformed), 401 Unauthorized (authenticate first), 403 Forbidden (authenticated but not allowed), 404 Not Found, 405 Method Not Allowed, 409 Conflict, 410 Gone, 415 Unsupported Media Type, 422 Unprocessable Entity (parsed but failed validation), 429 Too Many Requests.
  • 5xx — Server Error. "I broke something." Common: 500 Internal Server Error (catch-all), 502 Bad Gateway (upstream lied), 503 Service Unavailable (overloaded / down for maintenance), 504 Gateway Timeout (upstream didn't reply in time).

Dispatch on the Family, Refine on the Code

Production HTTP clients use the family digit as the primary branch:

  • 2xx → parse the response, return success.
  • 3xx → follow the redirect (or surface it).
  • 4xx → do NOT retry. The client did something wrong; retrying with the same input will give the same error.
  • 5xx → retry with exponential backoff. The server is having a moment; the same request might succeed later.
  • 1xx → usually invisible (HTTP libraries handle it for you).

Refinement on the specific code happens for known cases — 401 triggers an auth refresh, 429 reads Retry-After for delay, 409 may trigger a merge — but the family digit is the load-bearing decision.

Status codes are a polymorphic protocol, not a lookup table. If you write if status == 200: ... elif status == 201: ... elif status == 204: ..., you've reinvented a switch when you could have used if 200 <= status < 300:. The latter handles every future 2xx (some are still being standardized) without code changes.

The Three Most-Confused Pairs

401 vs 403 — 401 means "you have no credentials, or yours are invalid; try logging in." 403 means "your credentials are valid, but you're not allowed to do this." Returning 403 to an unauthenticated user leaks information ("this resource exists, you're just not allowed"); returning 401 when the user IS authenticated is just wrong.

400 vs 422 — 400 means the request was malformed (JSON wouldn't parse, required header missing). 422 means it parsed correctly but failed business validation (email field has "not-an-email"). FastAPI returns 422 by default for Pydantic validation errors, which is correct. Express defaults vary.

301 vs 308 (and 302 vs 307) — All four are redirects, but 301/302 historically allowed clients to change the method (POST → GET on follow). 307/308 explicitly forbid method change. Modern APIs should use 307/308 to avoid the "POST became GET" surprise.

cwkPippa's Status Code Vocabulary

cwkPippa's backend speaks ~10 codes regularly: 200 (most GETs), 201 (POST that created), 202 (long-running like council finalize), 204 (DELETE / PUT-no-body), 400 (malformed JSON), 401 (no auth), 403 (admin-only endpoint), 404 (missing conversation), 422 (Pydantic validation), 500 (uncaught backend exception). The frontend's fetch wrapper in frontend/src/lib/api.ts branches on status_code // 100 first, then refines.

Code

Family-first dispatch — works for every code that ever existed·python
# The polymorphic dispatcher pattern — never crashes on an unknown code
import httpx

def handle(resp: httpx.Response):
    family = resp.status_code // 100
    if family == 2:
        return resp.json() if resp.content else None        # success path
    if family == 3:
        return follow_redirect(resp.headers['location'])    # 3xx path
    if family == 4:
        # Client error — refine for known cases, don't retry
        if resp.status_code == 401: refresh_auth()
        elif resp.status_code == 429:
            delay = int(resp.headers.get('retry-after', 1))
            return retry_after(delay)
        raise ClientError(resp.status_code, resp.text)
    if family == 5:
        # Server error — retry with backoff
        return retry_with_backoff(resp.request)
    if family == 1:
        # 1xx — handled by the HTTP library; usually invisible
        return None
    raise Exception(f'Unknown status family: {resp.status_code}')
httpx — predicates per family + raise_for_status helper·python
# httpx ships convenience predicates for the same idea
import httpx
resp = httpx.get('https://example.com/')

print(resp.is_informational)  # 1xx
print(resp.is_success)        # 2xx
print(resp.is_redirect)       # 3xx (and httpx auto-follows by default)
print(resp.is_client_error)   # 4xx
print(resp.is_server_error)   # 5xx
print(resp.is_error)          # 4xx or 5xx

# Common idiom — raise for non-2xx
try:
    resp.raise_for_status()  # raises HTTPStatusError for 4xx/5xx
except httpx.HTTPStatusError as e:
    print(f'{e.response.status_code} on {e.request.url}')
FastAPI — raise HTTPException with status.* constants for readability·python
# Server side — FastAPI raises HTTPException with the right code
from fastapi import FastAPI, HTTPException, status

app = FastAPI()

@app.get('/users/{uid}')
async def read_user(uid: str):
    user = await db_find(uid)
    if user is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND, detail='no such user')
    return user

@app.post('/users')
async def create_user(payload: dict):
    if 'email' not in payload:
        # 422 — the request was well-formed, just failed validation
        raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY,
                            detail='email is required')
    return {'id': 'new', **payload}  # FastAPI defaults to 200; you can set status_code=201

@app.post('/admin/wipe')
async def wipe(user=Depends(current_user)):
    if user.role != 'admin':
        raise HTTPException(status.HTTP_403_FORBIDDEN)  # authenticated but not allowed
    ...

External links

Exercise

Write a small Python function classify(code: int) -> str that returns 'informational', 'success', 'redirect', 'client_error', or 'server_error' for any input. Then hit any public API (try cwkPippa's /api/health, GitHub's /users/octocat, a deliberately-broken URL on your own server) and classify the response. Bonus: extend classify to add a 'should_retry' boolean — only 5xx and 408/429 should be true.
Hint
Don't write a switch with 60 cases. code // 100 gives you the family digit; map 1→'informational', 2→'success', 3→'redirect', 4→'client_error', 5→'server_error' with a dict or if/elif chain. For retry: family == 5, or (family == 4 and code in {408, 429}). Future-proof to any new status code that ever ships.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.