C.W.K.
Stream
Lesson 02 of 06 · published

Status Code Dispatch — The Specific Codes That Earn Their Keep

~11 min · semantics, status-codes, dispatch, refinement

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"The family digit gets you 80% of the way. The remaining 20% — the difference between 201 and 204, 301 and 308, 401 and 403, 400 and 422 — is what separates a tolerable API from a respected one."

2xx — Success Has Five Shapes

All 2xx codes mean the request worked. The specific code tells the client how:

  • 200 OK — generic success. Response body contains the result. Use for GET, idempotent updates that return the new state, and most everyday success.
  • 201 Created — the request created a new resource. Response MUST include a Location header pointing at the new resource's URL. Often the body also carries the new resource. Use for POST that creates and PUT that creates (when the resource didn't exist).
  • 202 Accepted — the request was accepted for processing but isn't done yet. The body or Location header should point at a status URL the client can poll. Use for long-running async operations — Stripe payouts, video transcoding, council finalization.
  • 204 No Content — success, explicitly no body. Common after DELETE and after PUT/PATCH that don't return the updated resource. Clients that always call response.json() will crash on 204.
  • 206 Partial Content — response to a Range request. Used by video streaming, resumable downloads, big-file APIs. The body is a byte range of the resource, not the whole thing.

3xx — Redirects Differ on Method Preservation

The 3xx redirect pairs are the source of an entire class of bugs: a POST gets redirected, and the redirected request becomes a GET (because that's what 301/302 historically allowed). The fix: use the method-preserving codes.

  • 301 Moved Permanently — "this URL is gone forever; use the one in Location." Clients update their bookmarks. Historically allowed method-change on follow (POST → GET); modern guidance says don't.
  • 308 Permanent Redirect — same as 301 but EXPLICITLY method-preserving. POST stays POST. Use this for permanent moves of any non-GET endpoint.
  • 302 Found — temporary redirect. Historically allowed method-change.
  • 307 Temporary Redirect — same as 302 but explicitly method-preserving. POST stays POST. Use this for temporary redirects of any non-GET endpoint.
  • 304 Not Modified — the cached copy is still valid; no body returned. Conditional-request response (Track 2 lesson 4).

4xx — The Confused-Pair Hall of Fame

Four pairs that cost careers:

401 Unauthorized vs 403 Forbidden. 401 = "who are you?" — send credentials. 403 = "I know who you are, and no." Sending credentials won't help. Returning 403 to an unauthenticated user leaks resource existence; returning 401 when authentication succeeded just lies. WWW-Authenticate header should accompany 401 to tell the client HOW to authenticate.

400 Bad Request vs 422 Unprocessable Entity. 400 = "I couldn't parse your request." Wrong JSON, missing required header, malformed URL. 422 = "I parsed it fine, but the contents violated a business rule." Email field with "not-an-email," negative quantity. FastAPI/Pydantic gets this right; many hand-rolled APIs collapse both into 400 and lose the diagnostic signal.

404 Not Found vs 410 Gone. 404 = "I don't know if this ever existed or might exist in the future." 410 = "this existed, has been deleted, and is never coming back — stop checking." 410 lets clients prune their indexes; 404 doesn't carry that signal.

409 Conflict. The request can't be applied because of the resource's current state. Most common cases: PUT with stale ETag (use 412 Precondition Failed instead when you have the ETag), DELETE on a resource with active dependents, POST that violates a uniqueness constraint.

Specific codes are refinements, not replacements. A client written against the family digit handles 100% of HTTP responses ever shipped — including codes that don't exist yet. Refinement on the specific code adds intelligence (retry-with-delay on 429, refresh-auth on 401, follow-location on 201). But never invert the order: family first, code second.

5xx — All Mean Retry, But Differently

  • 500 Internal Server Error — the server caught an exception it didn't expect. Retry with backoff; may resolve on next deploy.
  • 502 Bad Gateway — your server is a gateway/proxy and the upstream gave a malformed response. Retry; the upstream is having a moment.
  • 503 Service Unavailable — the server is overloaded or down for maintenance. SHOULD include Retry-After with a delay. Respect it.
  • 504 Gateway Timeout — your server is a gateway and the upstream didn't reply in time. Retry; the upstream may have just been slow.

cwkPippa's Status Code Realities

cwkPippa returns 201 with Location for new conversations, 202 for async council finalization (the client polls for completion), 204 for archive-folder DELETE, 400 for malformed chat payloads, 401 for missing auth, 403 for non-admin trying to hit admin routes, 422 for Pydantic field validation, 429 with Retry-After when the OpenAI/Anthropic upstream rate-limits us, 500 for uncaught exceptions, and 503 with Retry-After when a backend brain (Codex/Gemini) is down. Every one of these decisions came from a real production moment.

Code

Emit the right code (FastAPI)·python
# Server side — emit the right code for each situation
from fastapi import FastAPI, HTTPException, status, Response
from fastapi.responses import JSONResponse

app = FastAPI()

@app.post('/users', status_code=201)
async def create_user(payload: dict, response: Response):
    user_id = create_in_db(payload)
    response.headers['Location'] = f'/users/{user_id}'
    return {'id': user_id, **payload}  # 201 + Location

@app.post('/videos/transcode', status_code=202)
async def transcode(payload: dict, response: Response):
    job_id = enqueue_job(payload)
    response.headers['Location'] = f'/jobs/{job_id}'
    return {'job_id': job_id, 'status': 'queued'}  # 202 + Location to poll

@app.delete('/users/{uid}', status_code=204)
async def delete_user(uid: str):
    delete_from_db(uid)
    return  # 204 — no body

@app.put('/users/{uid}', status_code=308)  # method-preserving permanent redirect
async def relocated(uid: str, response: Response):
    response.headers['Location'] = f'/v2/users/{uid}'  # POST→POST, PUT→PUT
    return None
Production client — family first, code refinement second·python
# Client side — branch on family digit, refine on specific codes
import httpx

def handle(resp: httpx.Response):
    family = resp.status_code // 100
    code = resp.status_code

    if family == 2:
        if code == 201:
            return {'created': True, 'location': resp.headers['Location'], **resp.json()}
        if code == 202:
            return {'async': True, 'poll': resp.headers['Location']}
        if code == 204:
            return None  # NO .json() — body is empty
        return resp.json()  # 200, 206, etc.

    if family == 4:
        if code == 401: refresh_auth_and_retry()
        elif code == 403: raise PermissionError(resp.text)
        elif code == 404: return None  # treat as missing
        elif code == 409: handle_conflict(resp)
        elif code == 410: forget_url_forever(resp.request.url)
        elif code == 422: raise ValidationError(resp.json())
        elif code == 429:
            wait = int(resp.headers.get('Retry-After', 1))
            return retry_after(wait)
        raise ClientError(code, resp.text)

    if family == 5:
        if code == 503:
            wait = int(resp.headers.get('Retry-After', 5))
            return retry_after(wait)
        return retry_with_backoff()
What real responses look like for each refined code·bash
# Watch each status code in action
# 201 with Location
curl -i -X POST https://api.example.com/users -d '{"name":"Pippa"}'
# HTTP/1.1 201 Created
# Location: /users/u_abc123
# Content-Type: application/json
# ...

# 204 — no body
curl -i -X DELETE https://api.example.com/users/42
# HTTP/1.1 204 No Content
# (blank body)

# 429 — respect Retry-After
curl -i https://api.example.com/heavy-endpoint
# HTTP/1.1 429 Too Many Requests
# Retry-After: 60
# Content-Type: application/json
# {"error":"rate_limited","window_seconds":60}

External links

Exercise

Build a tiny FastAPI server with FIVE endpoints, each returning a different status code: POST /items (201 + Location), POST /jobs (202 + Location), DELETE /items/{id} (204), GET /items/{id} with optional ?force_410=true (404 vs 410), POST /strict-create that returns 422 if payload is missing a required field. Call each with curl -i and inspect the response. Then write a Python client function that dispatches correctly on each one — never crashing, always doing the right thing per refined code.
Hint
FastAPI exposes status_code on the route decorator AND on Response.status_code mid-handler. Use response: Response injection for setting Location dynamically. On the client side, branch family first (the family check should be your outer if/elif), then refine. The dispatch should never else: raise Exception — for unknown codes, fall back to the family default.

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.