My app said “Saved” when nothing was saved
New to how a browser talks to a server? Open the quick recap. It takes about two minutes.
Quick recapClient and server in two minutes· 2 min
A client (a browser or a phone app) sends a request to a server over the network. The server does the work and sends back one response. HTTP is the format of both messages: plain text lines, then an optional body. HTTPS is the same HTTP, sent through an encrypted connection, so nobody on the way can read or change it.
See the problem
Section titled “See the problem”You build a small notes app. The phone app calls your API to save a note. One day a user types an empty title and taps Save. The app shows “Saved!”, but the note is not in the list.
Here is what the API sent back:
HTTP/1.1 200 OKContent-Type: application/json
{"error": "title can't be empty"}The app checks the status code, and 200 means “it worked”. So it never read the body. Your error dashboard counts zero errors too, for the same reason.
Define the task
Section titled “Define the task”Write the Notes API’s answer for each request: handle(notes, method, path, body, user).
- Input:
notes, a dict from note id to{"owner": ..., "title": ...}. Then the request’s method, path, JSON body text (orNone), and the logged-in user (orNone). - Output: a tuple
(status, headers, body). The body is a dict, orNonewhen there is nothing to send. - The API:
GET /noteslists your notes.POST /notesmakes one.GET /notes/<id>reads one, andDELETE /notes/<id>deletes it. - The hard part: every outcome needs the status code a client can act on without reading the body.
Example: Ana sends POST /notes with {"title": "Pay rent"}. Notes 1 and 2 exist, so the answer is (201, {"Location": "/notes/3"}, {"id": 3, "title": "Pay rent"}).
Pause and think
Section titled “Pause and think”Hints open one at a time:
Hint 1 of 3
A client decides what to do from the first digit. 2xx: it worked. 4xx: change the request. 5xx: the server failed, try later.
Hint 2 of 3
Not logged in and not allowed are different problems. One needs a login screen. The other needs nothing from the user.
Hint 3 of 3
“Not allowed” tells Ana that note 2 exists. For private data, 404 hides that.
Understand the idea
Section titled “Understand the idea”A request is a few lines of text. The first line has the method (what to do) and the path (what to do it to). Then come headers, one Name: value per line. A blank line ends them, and the body follows.
POST /notes HTTP/1.1Host: api.example.comAuthorization: Bearer abc123Content-Type: application/jsonContent-Length: 21
{"title": "Pay rent"}The response has the same shape. Its first line is the status line: the version, a three-digit code and a short reason, like HTTP/1.1 201 Created.
The method says what to do. Two words describe methods. A safe method changes nothing on the server. An idempotent method has the same effect done once or ten times.
| Method | Use it to | Safe | Idempotent |
|---|---|---|---|
GET | Read something | Yes | Yes |
POST | Make something new, or start an action | No | No |
PUT | Replace something with the body you send | No | Yes |
PATCH | Change some fields | No | Not always |
DELETE | Delete something | No | Yes |
HEAD, OPTIONS | Read only the headers, or ask what is allowed | Yes | Yes |
Delete note 3 twice, and note 3 is still gone, just once. Post the same note twice, and you have two notes. That is why a client can safely retry a GET or a DELETE, but not a POST.
The status code says what happened. The first digit is the family. Clients, caches, load balancers and dashboards all read it.
| Code | Means | Use it when |
|---|---|---|
| 200 OK | It worked; here is the answer | A read or a change worked |
| 201 Created | Something new exists | A POST made it. Add Location: /notes/3 |
| 204 No Content | It worked; no body | A DELETE worked |
| 301, 308 / 302, 307 | Go to another URL, for ever / for now | A page moved |
| 304 Not Modified | Your saved copy is still good | A cached read |
| 400 Bad Request | The request is wrong | Bad JSON, a missing field |
| 401 Unauthorized | Who are you? | No login, or an expired one |
| 403 Forbidden | I know you, but no | Logged in, but not allowed |
| 404 Not Found | Nothing here | A wrong path, or data you may not see |
| 405 Method Not Allowed | Not with that method | PUT where only GET works. Add Allow |
| 409 Conflict | It clashes with what’s there | The username is taken |
| 422 Unprocessable Content | The JSON is fine, the values aren’t | FastAPI uses it for a failed check |
| 429 Too Many Requests | Slow down | A rate limit. Add Retry-After |
| 500 Internal Server Error | The server’s code failed | An unhandled exception |
| 502, 503, 504 | A server on the way failed, is down, or was too slow | Try again later |
Watch three requests to the Notes API. Each one stops at a different place, and gets a different code.
One request, one response
Parts:
- Phone app (the request is here)
- Notes API
- Database
Arrows:
- Phone app sends requests to Notes API
- Notes API sends requests to Database
Step 1 of 9. The app sends a request: a method (POST), a path (/notes), headers (who is asking) and a body: {"title": "Pay rent"}.
Climb the ladder
Section titled “Climb the ladder”Three versions of handle. All three give the same body for each request. What changes is the status code, and so what a client can do with it.
Always 200, with the error in the body
The idea: Answer every request with 200 OK. When something is wrong, say so in an error field of the body.
It is easy to write, and a person reading the JSON can see what went wrong.
import json
Notes = dict[int, dict[str, str]]
def handle(notes: Notes, method: str, path: str, body: str | None, user: str | None): """Okay: every answer is 200 OK. What went wrong is only in the body.""" if user is None: return 200, {}, {"error": "log in first"} if path == "/notes" and method == "GET": mine = [{"id": i, **n} for i, n in notes.items() if n["owner"] == user] return 200, {}, {"notes": mine} if path == "/notes" and method == "POST": try: title = json.loads(body or "")["title"].strip() except (ValueError, KeyError, TypeError, AttributeError): return 200, {}, {"error": "send JSON with a title"} if not title: return 200, {}, {"error": "title can't be empty"} new_id = max(notes, default=0) + 1 notes[new_id] = {"owner": user, "title": title} return 200, {}, {"id": new_id, "title": title} if path.startswith("/notes/") and path[7:].isdigit(): note_id = int(path[7:]) note = notes.get(note_id) if note is None or note["owner"] != user: return 200, {}, {"error": "no such note"} if method == "GET": return 200, {}, {"id": note_id, "title": note["title"]} if method == "DELETE": del notes[note_id] return 200, {}, {"deleted": note_id} return 200, {}, {"error": "not found"}- Codes used
1: 200- Client must
read every body to find errors- Why
- Every outcome looks like success from the outside. Only code that reads the body can tell.
Where it breaks: the empty title from the first section. The app sees 200 and says “Saved!”. Caches may keep the error page. Monitoring counts no errors, so a broken release looks healthy.
The right family: 2xx, 4xx, 5xx
The idea: Use 200 when it worked, 400 when the request is wrong, and 404 when there is nothing there.
The waste in Okay: the code is the one part every client reads, and Okay throws it away. Now the empty title gets 400, so the app shows the error.
Highlighted lines are new in this rung.
import json
Notes = dict[int, dict[str, str]]
def handle(notes: Notes, method: str, path: str, body: str | None, user: str | None): """Better: the right family of codes. 2xx worked, 4xx is the client's fault.""" if user is None: return 400, {}, {"error": "log in first"} if path == "/notes" and method == "GET": mine = [{"id": i, **n} for i, n in notes.items() if n["owner"] == user] return 200, {}, {"notes": mine} if path == "/notes" and method == "POST": try: title = json.loads(body or "")["title"].strip() except (ValueError, KeyError, TypeError, AttributeError): return 400, {}, {"error": "send JSON with a title"} if not title: return 400, {}, {"error": "title can't be empty"} new_id = max(notes, default=0) + 1 notes[new_id] = {"owner": user, "title": title} return 200, {}, {"id": new_id, "title": title} if path.startswith("/notes/") and path[7:].isdigit(): note_id = int(path[7:]) note = notes.get(note_id) if note is None or note["owner"] != user: return 404, {}, {"error": "no such note"} if method == "GET": return 200, {}, {"id": note_id, "title": note["title"]} if method == "DELETE": del notes[note_id] return 200, {}, {"deleted": note_id} return 404, {}, {"error": "not found"}- Codes used
3: 200, 400, 404was 1: 200- Client must
read the body to tell 4xx errors apartwas read every body to find errors- Why
- The first digit is now right, so apps and dashboards can tell success from failure.
What changed from the previous rung
import json
Notes = dict[int, dict[str, str]]
def handle(notes: Notes, method: str, path: str, body: str | None, user: str | None): """Okay: every answer is 200 OK. What went wrong is only in the body.""" """Better: the right family of codes. 2xx worked, 4xx is the client's fault.""" if user is None: return 200, {}, {"error": "log in first"} return 400, {}, {"error": "log in first"} if path == "/notes" and method == "GET": mine = [{"id": i, **n} for i, n in notes.items() if n["owner"] == user] return 200, {}, {"notes": mine} if path == "/notes" and method == "POST": try: title = json.loads(body or "")["title"].strip() except (ValueError, KeyError, TypeError, AttributeError): return 200, {}, {"error": "send JSON with a title"} return 400, {}, {"error": "send JSON with a title"} if not title: return 200, {}, {"error": "title can't be empty"} return 400, {}, {"error": "title can't be empty"} new_id = max(notes, default=0) + 1 notes[new_id] = {"owner": user, "title": title} return 200, {}, {"id": new_id, "title": title} if path.startswith("/notes/") and path[7:].isdigit(): note_id = int(path[7:]) note = notes.get(note_id) if note is None or note["owner"] != user: return 200, {}, {"error": "no such note"} return 404, {}, {"error": "no such note"} if method == "GET": return 200, {}, {"id": note_id, "title": note["title"]} if method == "DELETE": del notes[note_id] return 200, {}, {"deleted": note_id} return 200, {}, {"error": "not found"} return 404, {}, {"error": "not found"}Where it breaks: a logged-out user also gets 400, the same as a bad title. The app can’t tell “show the login screen” from “fix the form”. PUT /notes gets 404, as if /notes didn’t exist. And a new note gets 200 with no Location, so the client must guess its URL.
One exact code per outcome, with its headers
The idea: 201 with Location for a new note and 204 for a delete. 401 for no login, 405 with Allow for a wrong method, and 400 or 404 for the rest.
The waste in Better: a 4xx code says “your fault”, but not which fault. The exact code tells the app what to do next: log in, fix the form, or stop.
Ben’s note gets 404, not 403. A 403 would tell Ana that note 2 exists. The method is checked before the login, so PUT /notes gets 405 even when nobody is logged in.
Highlighted lines are new in this rung.
import json
Notes = dict[int, dict[str, str]]
def handle(notes: Notes, method: str, path: str, body: str | None, user: str | None): """Best: one exact code for each outcome, plus the headers that go with it.""" if path == "/notes": allowed = "GET, POST" elif path.startswith("/notes/") and path[7:].isdigit(): allowed = "GET, DELETE" else: return 404, {}, {"error": "not found"} if method not in allowed.split(", "): return 405, {"Allow": allowed}, {"error": f"use {allowed}"} if user is None: return 401, {}, {"error": "log in first"}
if path == "/notes" and method == "GET": mine = [{"id": i, **n} for i, n in notes.items() if n["owner"] == user] return 200, {}, {"notes": mine} if path == "/notes": # POST try: title = json.loads(body or "")["title"].strip() except (ValueError, KeyError, TypeError, AttributeError): return 400, {}, {"error": "send JSON with a title"} if not title: return 400, {}, {"error": "title can't be empty"} new_id = max(notes, default=0) + 1 notes[new_id] = {"owner": user, "title": title} return 201, {"Location": f"/notes/{new_id}"}, {"id": new_id, "title": title}
note_id = int(path[7:]) note = notes.get(note_id) if note is None or note["owner"] != user: # someone else's note: don't say it exists return 404, {}, {"error": "no such note"} if method == "GET": return 200, {}, {"id": note_id, "title": note["title"]} del notes[note_id] # DELETE return 204, {}, None- Codes used
7: 200, 201, 204, 400, 401, 404, 405was 3: 200, 400, 404- Client must
read the code; the body is for peoplewas read the body to tell 4xx errors apart- Why
- Each code says what happened and what to do next. Headers like Location and Allow say the rest.
What changed from the previous rung
import json
Notes = dict[int, dict[str, str]]
def handle(notes: Notes, method: str, path: str, body: str | None, user: str | None): """Better: the right family of codes. 2xx worked, 4xx is the client's fault.""" """Best: one exact code for each outcome, plus the headers that go with it.""" if path == "/notes": allowed = "GET, POST" elif path.startswith("/notes/") and path[7:].isdigit(): allowed = "GET, DELETE" else: return 404, {}, {"error": "not found"} if method not in allowed.split(", "): return 405, {"Allow": allowed}, {"error": f"use {allowed}"} if user is None: return 400, {}, {"error": "log in first"} return 401, {}, {"error": "log in first"}
if path == "/notes" and method == "GET": mine = [{"id": i, **n} for i, n in notes.items() if n["owner"] == user] return 200, {}, {"notes": mine} if path == "/notes" and method == "POST": if path == "/notes": # POST try: title = json.loads(body or "")["title"].strip() except (ValueError, KeyError, TypeError, AttributeError): return 400, {}, {"error": "send JSON with a title"} if not title: return 400, {}, {"error": "title can't be empty"} new_id = max(notes, default=0) + 1 notes[new_id] = {"owner": user, "title": title} return 200, {}, {"id": new_id, "title": title} if path.startswith("/notes/") and path[7:].isdigit(): note_id = int(path[7:]) note = notes.get(note_id) if note is None or note["owner"] != user: return 404, {}, {"error": "no such note"} if method == "GET": return 200, {}, {"id": note_id, "title": note["title"]} if method == "DELETE": del notes[note_id] return 200, {}, {"deleted": note_id} return 404, {}, {"error": "not found"} return 201, {"Location": f"/notes/{new_id}"}, {"id": new_id, "title": title}
note_id = int(path[7:]) note = notes.get(note_id) if note is None or note["owner"] != user: # someone else's note: don't say it exists return 404, {}, {"error": "no such note"} if method == "GET": return 200, {}, {"id": note_id, "title": note["title"]} del notes[note_id] # DELETE return 204, {}, NoneWhen Best isn’t best: don’t invent codes, or use rare ones like 418. Clients only know the common ones. And frameworks pick many codes for you: Flask sends 405 with Allow by itself, and FastAPI sends 422 for a failed check. Know their defaults before you add your own.
Write it yourself
Section titled “Write it yourself”Now write handle in the page. Press Run tests to check your code. Python runs in your browser, so nothing is sent anywhere.
Each test runs one request against two notes: note 1 is Ana’s, and note 2 is Ben’s. Try Okay from the ladder first, and see how many tests it fails.
The Notes API’s status codes
Ctrl + Enter runs- Not run yet:
GET /notes as Ana→[200, {}, {'notes': [{'id': 1, 'owner': 'ana', 'title': 'Buy milk'}]}] - Not run yet:
POST /notes {"title": "Pay rent"} as Ana: a new note→[201, {'Location': '/notes/3'}, {'id': 3, 'title': 'Pay rent'}] - Not run yet:
POST /notes {"title": ""}: an empty title→400 - Not run yet:
GET /notes with nobody logged in→401 - Not run yet:
GET /notes/2 as Ana: Ben’s note→404 - Not run yet:
DELETE /notes/1 as Ana→[204, {}, None] - Not run yet:
PUT /notes: a method /notes doesn’t take→[405, {'Allow': 'GET, POST'}, {'error': 'use GET, POST'}] - Not run yet: Extra test
- Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Compare
Section titled “Compare”| Way | Codes | Good | Not so good | Use it when |
|---|---|---|---|---|
| Okay: always 200 | 1 | Easy to write | Apps, caches and dashboards think every failure worked | Never for an API |
| Better: the right family | 3 | Success and failure look different | Can’t tell “log in” from “fix the form” | A first version, to fix soon |
| Best: one code per outcome | 7 | The code says what to do next | You must know the codes, and your framework’s defaults | Always |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- A
GETmust not change data. Browsers, caches and link previews may send aGETby themselves. A link likeGET /notes/3/deletecan be clicked by a robot. - 500 is for your bugs, not for bad input. If bad input reaches an unhandled exception, check it earlier and send 400.
- 204 has no body. Send nothing after the headers. Some clients fail on a 204 with a body.
PUTreplaces the whole thing. Fields you leave out are gone. UsePATCHto change only some.- 404 vs 405: the path is wrong (404), or the path is right and the method is wrong (405, with
Allow). - Redirects: 301 and 302 may turn a
POSTinto aGET. 307 and 308 keep the method and the body.
Say it in the interview
Section titled “Say it in the interview”Questions to ask first
- Who calls this API: our own app, other teams, or the public?
- Should a user learn that someone else’s data exists?
- Will clients retry on their own? Then which requests must be safe to repeat?
A 60-second walkthrough
“Clients act on the status code, not the body, so every outcome gets its own code. A successful read is 200. A POST that makes a note is 201, with a Location header pointing at it. A delete is 204 with no body. Bad input is 400, or 422 in FastAPI. No login is 401, and logged in but not allowed is 403. For private data I send 404 instead, so nobody learns what exists. A wrong method is 405 with an Allow header. 5xx is only for server failures, so it’s what monitoring alerts on. And GET, PUT and DELETE are idempotent, so clients can retry them; POST needs an idempotency key.”
Likely follow-ups
- PUT vs PATCH?
PUTsends the whole new version and is idempotent.PATCHsends only the changes. - Is POST ever idempotent? Not by itself. The client sends an
Idempotency-Keyheader, and the server saves the first answer for that key and repeats it. - 400 or 422? Both mean “fix your request”. Many teams use 400 for JSON that can’t be read and 422 for values that fail a check.
- What do 502, 503 and 504 mean? A proxy in front got a bad answer (502), the service is down or overloaded (503), or it waited too long (504).
Practice
Section titled “Practice”Three problems, from easy to medium. Each has hints, tests you can run here, and its own Okay, Better and Best answers.
1. Read the status line (easy)
Section titled “1. Read the status line (easy)”Write read_status(line). It takes a response’s first line and gives back (code, reason, kind). The kind is "info", "success", "redirect", "client error" or "server error", from the first digit.
Example: read_status("HTTP/1.1 404 Not Found") is (404, "Not Found", "client error").
Hint 1 of 3
The reason can have spaces: “Not Found”. Split the line at most twice.
Hint 2 of 3
HTTP/2 tools may send no reason at all: “HTTP/1.1 200”. Then the reason is "".
Hint 3 of 3
code // 100 gives the first digit. A dict from 1 to 5 picks the kind.
Read the status line
Ctrl + Enter runs- Not run yet:
read_status('HTTP/1.1 200 OK')→[200, 'OK', 'success'] - Not run yet:
read_status('HTTP/1.1 404 Not Found')→[404, 'Not Found', 'client error'] - Not run yet:
read_status('HTTP/1.1 503 Service Unavailable')→[503, 'Service Unavailable', 'server error'] - Not run yet: Extra test
- Not run yet: Extra test
- Not run yet:
read_status("HTTP/1.1 204"): no reason→[204, '', 'success']
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Split on every space
The idea: Split the line on spaces and take the first three parts.
It works for 200 OK.
def read_status(line: str) -> tuple[int, str, str]: """Okay: split on spaces. A reason phrase with a space in it is cut short.""" _version, code, reason = line.split(" ")[:3] status = int(code) if status < 300: kind = "success" elif status < 400: kind = "redirect" elif status < 500: kind = "client error" else: kind = "server error" return status, reason, kind- Lines it reads
One-word reasons- Misses
Spaces in the reason, 1xx, no reason- Why
- “Not Found” is two words, so the reason is cut to “Not”.
Where it breaks: most reasons have spaces. And a line with no reason has only two parts, so the unpacking fails.
Split at most twice
The idea: line.split(' ', 2) keeps the whole reason in the third part.
The waste in Okay: it cuts the reason into words it then throws away.
Highlighted lines are new in this rung.
def read_status(line: str) -> tuple[int, str, str]: """Better: split at most twice, so the reason keeps its spaces.""" _version, code, reason = line.split(" ", 2) status = int(code) if status < 300: kind = "success" elif status < 400: kind = "redirect" elif status < 500: kind = "client error" else: kind = "server error" return status, reason, kind- Lines it reads
Lines with a reasonwas One-word reasons- Misses
1xx, no reasonwas Spaces in the reason, 1xx, no reason- Why
- The second argument of split caps the number of cuts.
What changed from the previous rung
def read_status(line: str) -> tuple[int, str, str]: """Okay: split on spaces. A reason phrase with a space in it is cut short.""" _version, code, reason = line.split(" ")[:3] """Better: split at most twice, so the reason keeps its spaces.""" _version, code, reason = line.split(" ", 2) status = int(code) if status < 300: kind = "success" elif status < 400: kind = "redirect" elif status < 500: kind = "client error" else: kind = "server error" return status, reason, kindWhere it breaks: 101 Switching Protocols is called a success, because the if chain starts at 300. HTTP/1.1 204 still fails.
The first digit picks the family
The idea: Split at most twice, let the reason be missing, and look the kind up by code // 100.
The waste in Better: an if chain repeats what the first digit already says.
Highlighted lines are new in this rung.
KINDS = {1: "info", 2: "success", 3: "redirect", 4: "client error", 5: "server error"}
def read_status(line: str) -> tuple[int, str, str]: """Best: the reason is optional, the first digit picks the family, and 1xx counts too.""" parts = line.strip().split(" ", 2) status = int(parts[1]) reason = parts[2] if len(parts) == 3 else "" return status, reason, KINDS[status // 100]- Lines it reads
All of themwas Lines with a reason- Misses
Nothing in the taskwas 1xx, no reason- Why
- The family is only the first digit, so a five-entry dict covers every code.
What changed from the previous rung
KINDS = {1: "info", 2: "success", 3: "redirect", 4: "client error", 5: "server error"}
def read_status(line: str) -> tuple[int, str, str]: """Better: split at most twice, so the reason keeps its spaces.""" _version, code, reason = line.split(" ", 2) status = int(code) if status < 300: kind = "success" elif status < 400: kind = "redirect" elif status < 500: kind = "client error" else: kind = "server error" return status, reason, kind """Best: the reason is optional, the first digit picks the family, and 1xx counts too.""" parts = line.strip().split(" ", 2) status = int(parts[1]) reason = parts[2] if len(parts) == 3 else "" return status, reason, KINDS[status // 100]When Best isn’t best: in real code, use http.HTTPStatus(404).phrase for the reason, and let your HTTP library read responses.
2. Is it safe to try again? (medium)
Section titled “2. Is it safe to try again? (medium)”A client’s request failed. Write should_retry(method, status, has_key). status is None when no answer came at all. has_key says the request sent an Idempotency-Key.
Retry only when both are true. The server may come back: no answer, or 429, 502, 503 or 504. And doing it twice is harmless: the method is idempotent, or there is a key.
Example: should_retry("POST", 503, False) is False. The first POST may have worked before the error, and a second would make a second order.
Hint 1 of 3
A 4xx other than 429 won’t change on its own. Sending the same request again gets the same answer.
Hint 2 of 3
Which methods do the same thing done once or twice? GET, HEAD, OPTIONS, PUT and DELETE.
Hint 3 of 3
Two checks joined by and: “worth it” and “harmless”. Methods may come in lower case.
Is it safe to try again?
Ctrl + Enter runs- Not run yet:
should_retry('GET', None, False)→True - Not run yet:
should_retry('GET', 503, False)→True - Not run yet:
should_retry("POST", 503, False): it may make a second order→False - Not run yet:
should_retry("POST", 503, True): the key makes it safe→True - Not run yet:
should_retry('GET', 400, False)→False - Not run yet:
should_retry("GET", 429, False): wait, then try again→True - Not run yet: Extra test
- Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Retry every failure
The idea: If there was no answer, or the code is 400 or more, try again.
A busy server does sometimes recover, so retrying is a fair first idea.
def should_retry(method: str, status: int | None, has_key: bool) -> bool: """Okay: try again whenever it didn't work.""" return status is None or status >= 400- Retries
Every failure- Risk
Double orders, wasted calls- Why
- It treats “your request is wrong” like “the server is busy”.
Where it breaks: a 400 will fail the same way every time. And a POST that timed out may have worked, so the retry makes a second order.
Retry only server trouble
The idea: Retry when there was no answer, or on 502, 503 or 504.
The waste in Okay: it repeats requests that can never work.
Highlighted lines are new in this rung.
def should_retry(method: str, status: int | None, has_key: bool) -> bool: """Better: only server trouble, or no answer at all, is worth another try.""" return status is None or status in (502, 503, 504)- Retries
No answer, 502, 503, 504was Every failure- Risk
Double orderswas Double orders, wasted calls- Why
- Only a failure on the server’s side can go away by itself.
What changed from the previous rung
def should_retry(method: str, status: int | None, has_key: bool) -> bool: """Okay: try again whenever it didn't work.""" return status is None or status >= 400 """Better: only server trouble, or no answer at all, is worth another try.""" return status is None or status in (502, 503, 504)Where it breaks: it still retries a POST, so a payment can go through twice. And it gives up on 429, which only means “wait a little”.
Worth it, and harmless
The idea: Retry when the server may come back (no answer, 429, 502, 503, 504), and the method is idempotent or the request has a key.
The waste in Better: it asks “could it work?” but never “could it do harm?”.
Highlighted lines are new in this rung.
IDEMPOTENT = {"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}
def should_retry(method: str, status: int | None, has_key: bool) -> bool: """Best: retry only when the server may come back, and doing it twice is harmless.""" worth_it = status is None or status in (429, 502, 503, 504) harmless = method.upper() in IDEMPOTENT or has_key return worth_it and harmless- Retries
Only safe oneswas No answer, 502, 503, 504- Risk
None from the retry itselfwas Double orders- Why
- The second check stops any retry that could do the work twice.
What changed from the previous rung
IDEMPOTENT = {"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}
def should_retry(method: str, status: int | None, has_key: bool) -> bool: """Better: only server trouble, or no answer at all, is worth another try.""" return status is None or status in (502, 503, 504) """Best: retry only when the server may come back, and doing it twice is harmless.""" worth_it = status is None or status in (429, 502, 503, 504) harmless = method.upper() in IDEMPOTENT or has_key return worth_it and harmlessWhen Best isn’t best: a retry also needs a wait that grows each time, a cap on tries, and a look at Retry-After. Otherwise every client retries at once and keeps the server down.
3. Read a raw request (medium)
Section titled “3. Read a raw request (medium)”Write read_request(text). It takes a whole HTTP/1.1 request as text and gives back (method, path, headers, body). Lines end in \r\n, as HTTP says. Header names are case-insensitive, so give them back in lower case. The body is exactly Content-Length characters long, and is empty when there is no Content-Length.
Example: "GET /notes HTTP/1.1\r\nHost: x\r\n\r\n" gives ("GET", "/notes", {"host": "x"}, "").
Hint 1 of 3
A blank line ends the headers. With \r\n line ends, that is "\r\n\r\n". str.partition splits at the first one.
Hint 2 of 3
A header value can have a colon in it, like a time. Split each header line at its first colon only.
Hint 3 of 3
A connection can hold the next request right after this body. So cut the body at Content-Length.
Read a raw request
Ctrl + Enter runs- Not run yet:
read_request('GET /notes HTTP/1.1\r\nHost: x\r\n\r\n')→['GET', '/notes', {'host': 'x'}, ''] - Not run yet:
read_request('POST /notes HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}')→['POST', '/notes', {'content-type': 'application/json', 'content-length': '2'}, '{}'] - Not run yet:
A header value with a colon in it: X-Time: 10:30→['GET', '/', {'x-time': '10:30'}, ''] - Not run yet:
The next request follows the body: cut at Content-Length→['POST', '/a', {'content-length': '3'}, 'abc'] - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Split on newlines and colons
The idea: Split the head from the body at a blank line, and each header at its colon.
It reads a request you typed in a text editor.
def read_request(text: str) -> tuple[str, str, dict[str, str], str]: """Okay: split on newlines and colons. Breaks on CRLF, on a colon in a value, and on case.""" head, _, body = text.partition("\n\n") lines = head.split("\n") method, path, _version = lines[0].split(" ") headers = {} for line in lines[1:]: name, value = line.split(":") headers[name] = value.strip() return method, path, headers, body- Reads
Hand-typed text with \n- Misses
\r\n, colons in values, case- Why
- Real requests end lines with \r\n, so the blank line is never found.
Where it breaks: a real request. X-Time: 10:30 has two colons, so unpacking fails. And Content-Type and content-type become two keys.
CRLF, the first colon, lower-case names
The idea: Split on \r\n, partition each header at its first colon, and lower-case the name.
The waste in Okay: it ignores the line ends and the rules HTTP sets for header names.
Highlighted lines are new in this rung.
def read_request(text: str) -> tuple[str, str, dict[str, str], str]: """Better: CRLF line ends, the first colon only, and names in lower case.""" head, _, body = text.partition("\r\n\r\n") lines = head.split("\r\n") method, path, _version = lines[0].split(" ") headers = {} for line in lines[1:]: name, _, value = line.partition(":") headers[name.strip().lower()] = value.strip() return method, path, headers, body- Reads
One request at a timewas Hand-typed text with \n- Misses
Where the body endswas \r\n, colons in values, case- Why
- partition splits once, so the value keeps its colons.
What changed from the previous rung
def read_request(text: str) -> tuple[str, str, dict[str, str], str]: """Okay: split on newlines and colons. Breaks on CRLF, on a colon in a value, and on case.""" head, _, body = text.partition("\n\n") lines = head.split("\n") """Better: CRLF line ends, the first colon only, and names in lower case.""" head, _, body = text.partition("\r\n\r\n") lines = head.split("\r\n") method, path, _version = lines[0].split(" ") headers = {} for line in lines[1:]: name, value = line.split(":") headers[name] = value.strip() name, _, value = line.partition(":") headers[name.strip().lower()] = value.strip() return method, path, headers, bodyWhere it breaks: it takes everything after the blank line as the body. On a kept-open connection, the next request ends up in the body.
Cut the body at Content-Length
The idea: Like Better, then keep only Content-Length characters of the body.
The waste in Better: the request says how long its body is, and Better doesn’t ask.
Highlighted lines are new in this rung.
def read_request(text: str) -> tuple[str, str, dict[str, str], str]: """Best: like Better, and the body is exactly Content-Length characters long.""" head, _, rest = text.partition("\r\n\r\n") lines = head.split("\r\n") method, path, _version = lines[0].split(" ") headers = {} for line in lines[1:]: name, _, value = line.partition(":") headers[name.strip().lower()] = value.strip() length = int(headers.get("content-length", "0")) return method, path, headers, rest[:length]- Reads
Requests one after anotherwas One request at a time- Misses
Nothing in the taskwas Where the body ends- Why
- Content-Length is how HTTP/1.1 says where one body ends and the next request starts.
What changed from the previous rung
def read_request(text: str) -> tuple[str, str, dict[str, str], str]: """Better: CRLF line ends, the first colon only, and names in lower case.""" head, _, body = text.partition("\r\n\r\n") """Best: like Better, and the body is exactly Content-Length characters long.""" head, _, rest = text.partition("\r\n\r\n") lines = head.split("\r\n") method, path, _version = lines[0].split(" ") headers = {} for line in lines[1:]: name, _, value = line.partition(":") headers[name.strip().lower()] = value.strip() return method, path, headers, body length = int(headers.get("content-length", "0")) return method, path, headers, rest[:length]When Best isn’t best: never write this for real. Length counts bytes, not characters, and bodies can come in chunks. Web servers like Gunicorn and Uvicorn do this for you, which is the next lesson.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”- A request is a method, a path, headers and a body. A response is a status code, headers and a body.
- Safe:
GET,HEAD,OPTIONS. Idempotent: those, plusPUTandDELETE.POSTis neither. - 2xx worked, 4xx fix your request, 5xx the server failed.
- 201 +
Locationfor new things, 204 for deletes, 401 no login, 403 not allowed, 404 nothing here, 405 +Allowwrong method, 429 slow down. - Retry only when the server may come back and repeating is harmless.
Go deeper
Section titled “Go deeper”- The HTTP standard, in plain sections: RFC 9110, HTTP Semantics, with methods and status codes.
- MDN’s guides to HTTP methods and status codes.
- In Python:
http.HTTPStatus, every code with its name and phrase. - Next on the Frameworks roadmap: WSGI vs ASGI, how a Python app gets these requests from the web server.
Can you do these now?
If you’re not sure of any of these, scroll back to that part before moving on.
Status: Not started