Module 05: HTTP Responses and Status Codes
Why this matters
The status code is the server's three-digit executive summary of what
happened. It's the first thing every client — browser, library, proxy,
cache, monitoring tool — looks at, and it drives automatic behavior: a
301 makes a browser remember a new URL forever, a 304 makes it reuse a
cached copy, a 429 makes a well-written client back off and retry, a
503 makes a load balancer mark the backend unhealthy. Return the wrong
code and every one of those automatic behaviors does the wrong thing.
The most common failure in real APIs is returning 200 OK for everything
and stuffing {"error": "not found"} in the body. Now every client has to
parse your body to discover something the status line was designed to tell
them at a glance — caches cache your errors, retry logic can't tell a
retryable failure from a permanent one, and monitoring can't distinguish
"my API is healthy" from "my API is returning errors with a smile." Codes
are a contract; this module is about honoring it.
You've already met 200, 201, 204, 404, and 405 in modules 03-04.
Here we complete the map — all five classes, the codes you'll actually use
weekly, and the precise "when do I return which" judgment that makes an API
feel correct.
Concepts
The five classes
The first digit classifies the response:
1xxInformational — interim, rarely seen directly (100 Continue,101 Switching Protocolsfor WebSocket upgrades). You mostly don't return these by hand.2xxSuccess — the request succeeded.200,201,202,204.3xxRedirection — more action needed, usually "go look elsewhere."301,302,304,307,308.4xxClient error — the caller did something wrong; don't retry unchanged.400,401,403,404,405,409,422,429.5xxServer error — the server failed; the request may be fine and worth retrying.500,502,503,504.
1xx ┤ informational "hold on, interim update" 100, 101
2xx ┤ success "here you go" 200 201 202 204
3xx ┤ redirection "go look elsewhere / use cache"301 302 304 307 308
4xx ┤ client error "YOU broke it — don't retry" 400 401 403 404 409 422 429
5xx ┤ server error "I broke it — retry may work" 500 502 503 504
└──────────────────────────────────────────────────────────►
▲ the 4xx│5xx line: whose fault, and retry-or-not
The 4xx/5xx split is the most consequential distinction in all of
HTTP: 4xx means "you broke it, fixing your request might help"; 5xx
means "I broke it, your request might be fine — retry may succeed." Retry
logic, alerting, and blame all hinge on this line.
The 2xx codes worth knowing
200 OK— generic success with a body.GETthat found something,PUT/PATCHthat returns the updated resource.201 Created— a new resource was created. Pair with aLocationheader pointing at it (module 03). The canonicalPOSTsuccess.202 Accepted— the request was accepted for asynchronous processing; it isn't done yet. Used when work is queued (a later track's background jobs). The response often includes a status URL to poll.204 No Content— success, and there is deliberately no body. The canonicalDELETEsuccess, and a validPUT/PATCHresult when you don't echo the resource back. A204must not have a body.
The 3xx codes worth knowing
301 Moved Permanently— this resource lives at a new URL forever; update your bookmarks/links. Browsers and search engines cache this aggressively — a301is hard to take back, so be sure. IncludeLocationwith the new URL.302 Found/307 Temporary Redirect— go here for now, but keep using the original URL next time.307is the modern, method-preserving version (a307on a POST re-POSTs; historically302sometimes got turned into a GET, which307fixes).308 Permanent Redirect— like301but method-preserving.304 Not Modified— a conditional response: "your cached copy is still current; reuse it, I'm sending no body." This is the beating heart of HTTP caching (module 06) and saves enormous bandwidth. It's a3xxbecause it tells the client to take action (use its cache).
Location is the header that says where for the redirect family (module
02).
The 4xx codes worth knowing
400 Bad Request— the request is malformed or nonsensical (bad JSON syntax, missing required field the server can't even parse). "I can't understand this."401 Unauthorized— you're not authenticated (no/invalid credentials). Misnamed: it means unauthenticated. Pair withWWW-Authenticate(module 02).403 Forbidden— you're authenticated but not allowed to do this. "I know who you are; you can't." (401 = who are you; 403 = you can't.)404 Not Found— no such resource. Also used to hide existence ("I won't confirm this exists to you").405 Method Not Allowed— the resource exists but not for this method; include anAllowheader (module 03).409 Conflict— the request conflicts with current state (e.g. creating a user whose email already exists; an edit based on a stale version). "This clashes with reality."422 Unprocessable Entity— the syntax is fine but the semantics are invalid (well-formed JSON, butage: -5fails validation). This is what FastAPI returns for validation errors. The distinction from400:400= "I can't parse this";422= "I parsed it, but the values are invalid."429 Too Many Requests— rate limited. Pair withRetry-Aftertelling the client when to try again (module 02).
The 5xx codes worth knowing
500 Internal Server Error— an unhandled exception / bug in your code. The generic "something blew up on my side." Never leak the stack trace to clients in production.502 Bad Gateway— a proxy/load balancer got an invalid response from the upstream server it forwarded to (module 00's proxy layer). Often means your app crashed or isn't speaking HTTP correctly behind the proxy.503 Service Unavailable— the server is temporarily unable to handle the request (overloaded, in maintenance, no healthy backends). Pair withRetry-After. A load balancer with zero healthy backends returns this.504 Gateway Timeout— a proxy waited for the upstream and it never answered in time (module 00: the upstream was too slow or hung).
502, 503, 504 are the trio you'll debug when "the API is down" —
they tell you which layer failed: 502 = upstream gave garbage, 503 =
no capacity, 504 = upstream too slow.
Choosing the right code: the decision path
When writing a handler, walk this:
- Did the client's request make sense syntactically? No →
400. - Are they authenticated (if required)? No →
401. - Are they allowed to do this? No →
403. - Does the target resource exist? No →
404. - Is the method supported here? No →
405. - Is the body valid per your rules? No →
422. - Does it conflict with current state? Yes →
409. - Are they over their rate limit? Yes →
429. - Otherwise do the work. Created something new? →
201+Location. Nothing to return? →204. Otherwise →200. - If your code fails doing the work →
500(or502/503/504at the infra layer).
request ─► parse OK? ──no──► 400
│yes
▼ authenticated? ──no──► 401
│yes
▼ allowed? ──────────no──► 403
│yes
▼ resource exists? ──no──► 404
│yes
▼ method OK? ────────no──► 405 (+ Allow)
│yes
▼ body valid? ──────no──► 422
│yes
▼ conflicts? ──────yes──► 409
│no
▼ over rate limit? ─yes──► 429 (+ Retry-After)
│no
▼ do the work ──► created? 201 (+Location) · empty? 204 · else 200
└─ blew up? ──► 500 / 502 / 503 / 504
The response body for errors
A 4xx/5xx should still carry a machine-readable body explaining the
problem — but the status code must independently convey the class. A
common good shape is RFC 9457 "problem details":
{
"type": "https://api.example.com/errors/validation",
"title": "Validation failed",
"status": 422,
"detail": "age must be >= 0",
"errors": [{"field": "age", "message": "must be >= 0"}]
}
The rule: the body elaborates; the status code classifies. Never make the client parse the body to learn success vs. failure.
Command reference
| Command | What it does |
|---|---|
curl -o /dev/null -s -w '%{http_code}\n' URL |
Print just the status code |
curl -i URL |
Show status line + headers + body |
curl -s -D - -o /dev/null URL |
Show status line + headers only |
curl -L URL |
Follow 3xx redirects (uses Location) |
curl -i -X DELETE URL |
See a 204/404 for a delete |
raise HTTPException(status_code=..., detail=...) (FastAPI) |
Return an error code from code |
Notes:
-w '%{http_code}'writes out just the numeric code — great for scripting/monitoring ("is this endpoint returning 2xx?").-Lmakes curl follow redirects by issuing a new request to theLocationURL. Without it, you see the301/302itself.- In FastAPI, returning a value →
200(or the route's declaredstatus_code);raise HTTPException(status_code=404, detail="...")produces a4xx/5xxwith a JSON body; validation failures auto-produce422.
Example app used below:
from fastapi import FastAPI, HTTPException, Response, status
from pydantic import BaseModel, Field
app = FastAPI()
USERS = {1: {"id": 1, "name": "Ada", "email": "ada@x.com"}}
class NewUser(BaseModel):
name: str
email: str
age: int = Field(ge=0) # age must be >= 0
@app.get("/users/{uid}")
def get_user(uid: int):
if uid not in USERS:
raise HTTPException(status_code=404, detail="user not found")
return USERS[uid]
@app.post("/users", status_code=status.HTTP_201_CREATED)
def create(user: NewUser, response: Response):
for u in USERS.values():
if u["email"] == user.email:
raise HTTPException(status_code=409, detail="email already exists")
uid = max(USERS) + 1
USERS[uid] = {"id": uid, **user.model_dump()}
response.headers["Location"] = f"/users/{uid}"
return USERS[uid]
@app.delete("/users/{uid}", status_code=status.HTTP_204_NO_CONTENT)
def delete(uid: int):
USERS.pop(uid, None)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@app.get("/boom")
def boom():
raise RuntimeError("simulated bug") # becomes a 500
Hands-on exercises
Run with uvicorn app:app --reload.
1. Read just the code
curl -o /dev/null -s -w 'GET /users/1 -> %{http_code}\n' http://127.0.0.1:8000/users/1
curl -o /dev/null -s -w 'GET /users/99 -> %{http_code}\n' http://127.0.0.1:8000/users/99
Expected: 200 then 404. The code alone tells you success vs.
not-found — no body parsing needed.
2. 201 + Location on create
curl -s -i -X POST -H 'Content-Type: application/json' \
-d '{"name":"Lin","email":"lin@x.com","age":30}' http://127.0.0.1:8000/users
Expected: HTTP/1.1 201 Created and a location: /users/2 header. That's
the canonical create response (module 03).
3. 204 on delete — and no body
curl -s -i -X DELETE http://127.0.0.1:8000/users/2
Expected: HTTP/1.1 204 No Content and no response body. Confirm the
body is empty — a 204 must not send one.
4. 422 vs 400 — validation vs. unparseable
# Well-formed JSON, invalid value (age negative) -> 422
curl -s -i -X POST -H 'Content-Type: application/json' \
-d '{"name":"X","email":"x@x.com","age":-5}' http://127.0.0.1:8000/users
# Malformed JSON -> 422/400 depending; note the difference
curl -s -i -X POST -H 'Content-Type: application/json' \
-d '{"name":"X", "email":' http://127.0.0.1:8000/users
Expected: the first returns 422 Unprocessable Entity with a body
pointing at the age field (parsed fine, value invalid). The second is a
parse failure. Internalize the distinction: 422 = valid syntax, invalid
semantics; 400 = can't even parse.
5. 409 conflict
curl -s -i -X POST -H 'Content-Type: application/json' \
-d '{"name":"Ada2","email":"ada@x.com","age":40}' http://127.0.0.1:8000/users
Expected: 409 Conflict — ada@x.com already exists. The request was
well-formed and valid, but conflicts with current state.
6. 500 — and what leaks
curl -s -i http://127.0.0.1:8000/boom
Expected: 500 Internal Server Error. In --reload/debug mode you may
see a traceback; in production you must not leak it. Note this is a
5xx (server's fault) — a client would be right to retry, though here it
will fail again because it's a real bug.
7. Follow a redirect
FastAPI adds a redirect for trailing-slash mismatches by default. Try:
curl -s -i http://127.0.0.1:8000/users/ # note trailing slash
curl -s -i -L http://127.0.0.1:8000/users/
Expected: without -L you see a 307/308 with a location header;
with -L, curl follows it to the final response. Seeing the redirect
itself vs. following it is the difference -L makes.
8. Map the decision path
For each scenario, write the code you'd return before checking, then verify your reasoning against the decision path in Concepts:
- Request body isn't valid JSON at all. → ?
- Valid JSON, but missing a required field / bad value. → ?
- No auth token on a protected route. → ?
- Valid token, but this user can't access this resource. → ?
- Resource genuinely doesn't exist. → ?
- Creating something whose unique key already exists. → ?
- Client has made 10,000 requests this minute. → ?
Expected: 400, 422, 401, 403, 404, 409, 429. If any were
wrong, reread that code's paragraph.
9. Diagnose and fix: 200-for-everything
Here's a broken handler. Add it and see why it's bad:
@app.get("/legacy/users/{uid}")
def legacy_get(uid: int):
if uid not in USERS:
# BUG: signals "not found" with a 200 and an error body
return {"error": "user not found"}
return USERS[uid]
curl -o /dev/null -s -w '%{http_code}\n' http://127.0.0.1:8000/legacy/users/99
curl -s http://127.0.0.1:8000/legacy/users/99
Expected: status 200 even though the user doesn't exist, with an
{"error": ...} body. Diagnose: every client, cache, and monitor now
believes this succeeded. A cache may store the "error" as a valid
response; a retry-on-5xx client won't retry; a dashboard counting non-2xx
sees zero errors. Fix: raise HTTPException(status_code=404, detail="user not found") so the status line carries the truth and the
body merely elaborates. Re-run: the code is now 404. Lesson: classify
with the status code; elaborate in the body.
Independent challenge
No code given.
Task: Build a /orders endpoint set that returns the semantically
correct status code for every one of these situations, and prove each
with a curl command that prints just %{http_code}: (1) successfully
fetching an existing order; (2) creating a new order; (3) fetching an
order that doesn't exist; (4) creating an order with a negative quantity;
(5) creating a duplicate order (same idempotency key/unique field as an
existing one); (6) deleting an order; (7) an internal failure while
processing. For the create-success case, also verify the correct header
that points at the new resource is present (from module 03). Do not use
200 for any error case. This exercises the full decision path and the
method/code pairings from module 03.
Hint
The seven expected codes are 200, 201, 404, 422, 409, 204,
500. For (2), remember 201 needs a Location header. For (7), let an
exception propagate (or raise HTTPException(status_code=500)) — but in a
real app you'd catch it and return a clean problem-details body without
leaking internals.
Common mistakes & troubleshooting
200for errors. The cardinal sin. Clients, caches, and monitoring all key off the status code; hiding failures behind200breaks them all.- Confusing
401and403.401= not authenticated (who are you?);403= authenticated but not permitted (you can't). Wrong choice confuses clients about whether to re-login. - Confusing
400and422.400= unparseable/malformed;422= parsed fine but failed validation rules. 301used casually. It's permanent and cached hard by browsers/ search engines — painful to reverse. Use302/307for temporary.204with a body. A204must have no body; sending one is malformed and confuses clients.- Not distinguishing
502/503/504. They pinpoint which layer failed (upstream garbage / no capacity / upstream too slow) — key for ops debugging. - Leaking stack traces on
500. Never expose internal errors to clients; log them server-side, return a clean generic message. - Missing
Retry-Afteron429/503. Well-behaved clients honor it to back off; without it they hammer you.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- What is the single most important distinction between a
4xxand a5xxcode, and how does it affect retry logic? - A client sends syntactically valid JSON, but
ageis-5and your rules requireage >= 0. What code, and how does it differ from the code for genuinely malformed JSON? - Distinguish
401and403in one sentence each. - What status code and what header should a successful resource-creating
POSTreturn? - What does
304 Not Modifiedtell the client to do, and which upcoming module is it central to? - You're debugging "the site is down." You see
502from the load balancer for some requests and504for others. What does each tell you about where the failure is? - Why is returning
200with{"error": "..."}for a missing resource harmful? Name two concrete things it breaks.
Answers
4xx= the client's request was wrong (retrying it unchanged won't help);5xx= the server failed (the request may be fine, so a retry might succeed). Retry logic should generally retry5xx(and429with backoff) but not blindly retry4xx.422 Unprocessable Entity— the JSON parsed fine but a value failed validation. Malformed/unparseable JSON is400 Bad Request("I can't even parse this"), versus422("I parsed it; the values are invalid").401= you are not authenticated (no/invalid credentials — "who are you?").403= you are authenticated but not permitted to do this ("you can't").201 Created, with aLocationheader pointing at the new resource's URL.- It tells the client its cached copy is still valid, so it should reuse the cache; the server sends no body. It's central to module 06 (HTTP caching).
502 Bad Gateway= the proxy got an invalid/garbage response from the upstream app (likely crashed or misbehaving).504 Gateway Timeout= the proxy waited and the upstream never responded in time (upstream hung or too slow).- It lies to every consumer of the status code. E.g.: caches may store the "error" as a successful response; monitoring counting non-2xx sees no errors; retry-on-5xx logic won't retry a genuine failure; clients must parse the body to learn success vs. failure. (Any two.)
Further reading & sources
- MDN: HTTP response status codes - the complete reference, one page per code, grouped by class.
- RFC 9110 §15: Status Codes - the authoritative definitions and the semantics of each class.
- RFC 9457: Problem Details for HTTP APIs - the standard error-body shape shown in Concepts.
- MDN: 422 Unprocessable Content - the validation-vs-parse distinction that trips people (400 vs 422).
- FastAPI: Handling errors - how
HTTPExceptionand validation map to the codes used in the exercises.
Next
06-http-caching — you just met 304;
now we build the whole caching system around it: Cache-Control, ETag,
Last-Modified, and conditional requests.