Module 01 · ~5 hours · Priority: High

How the Web Actually Works

Before you write a server, know what a server receives. DNS, TCP, TLS, and the plain-text request underneath every framework — then send one by hand and watch it come back.

Client and server the model

Everything in this module is true of every backend ever written. No framework appears until Module 02 — this is the layer underneath all of them.

What
A client sends a request, a server sends back exactly one response, and then the exchange is over.
Where
Between your browser and every website, between your phone app and its API, between your future FastAPI process and whoever calls it.
When
Before you write a single endpoint. An endpoint is a function that receives a request; you cannot design one without knowing what a request is.
How
Read the shape of the exchange here, then send one by hand at the end of this module and watch the bytes come back.

The single most important property: the server never speaks first. It sits in a loop waiting. It cannot ring you up, cannot push you a message, cannot check on you. Something outside it must knock, and only then does it reply — once. Then it forgets you.

client server | | | 1. request (I want GET /places/42) | |----------------------------------------> | | | looks it up | 2. response (200, here is the JSON) | |<---------------------------------------- | | | x exchange over. server remembers nothing.

Beginners usually arrive with a wrong mental model borrowed from desktop programs: that the server is a long-running conversation partner holding your place. It is not. HTTP is stateless — each request arrives carrying no memory of any request before it. Two requests one millisecond apart from the same laptop look, to the server, like two strangers.

  • Stateless is a deliberate trade, not an oversight. Because no request depends on server memory, any one of ten identical servers can answer any request. That is what makes scaling a matter of running more copies rather than rewriting the application.
  • What it costs you is identity. If the server forgets you between requests, you must re-prove who you are on every single request. That is the entire reason cookies, sessions, and tokens exist — Module 07 builds one from scratch, and it will make far more sense once you already feel the hole it fills.
  • One request, one response, always. Not zero, not two. If your server crashes mid-handler the client gets an error response or a dropped connection — but there is no version of HTTP where a request politely goes unanswered.
  • "Client" does not mean "browser". curl is a client. Your test suite in Module 09 is a client. One of your services calling another is a client. Nothing in the request says a human is involved.
The exceptions prove the rule. Push notifications, live chat, and WebSockets do let a server send you things unprompted — but every one of them starts with the client opening the connection first, and holding it open. Nobody ever solved the problem of a server dialling out to a client that has not knocked. Do not reach for these; plain request/response covers this entire track.

What happens when you type a URL

What
Four separate mechanisms — naming, connecting, encrypting, and asking — that run in that order and are usually mistaken for one thing.
Where
Every address bar, every curl command, every requests.get() line you will ever write.
When
You need this the first time something fails, because each of the four fails with a distinctly different error message.
How
Learn the anatomy of the URL, then walk the sequence once. That is enough forever.

Start with the address itself. A URL is not one string — it is six fields glued together with punctuation, and each field is consumed by a different part of the system:

https://api.cityguide.local:8000/places/42?sort=rating&limit=10#reviews \___/ \_________________/ \__/\________/ \_______________/ \_____/ | | | | | | scheme host port path query fragment | | | | | | which which which which extra NEVER SENT protocol machine program thing parameters to the server

That last one surprises everyone. The fragment — everything after # — is stripped by the client and never leaves your machine. It exists so a browser can scroll to a heading. Your server has no way to read it, ever. If you find yourself designing an API that needs the fragment, you have designed a query parameter instead.

The port is the other quiet one. You rarely type it because the scheme implies a default: http means port 80, https means port 443. During this track you will type :8000 constantly, because your own server runs there and nothing defaults to it.

Now the sequence. Four mechanisms, in order:

1. DNS "api.cityguide.local" ---> 93.184.216.34 a name lookup. Names are for humans; the network only routes to numbers. Fails: NXDOMAIN / "could not resolve host" 2. TCP client --- SYN ------> server a three-step handshake that client <-- SYN-ACK --- server opens a reliable, ordered client --- ACK ------> server byte pipe to ONE port Fails: "connection refused" = nothing is listening there 3. TLS certificate + key exchange, then everything after is encrypted (skipped entirely for plain http://) Fails: "certificate verify failed" 4. HTTP ---> GET /places/42 HTTP/1.1 (plain text, over that pipe) <--- 200 OK + body Fails: a status code. The connection worked fine.

TLS in one paragraph, because it is the most over-mystified item on that list. TLS gives you two things and only two: confidentiality, meaning nobody between you and the server can read or alter the bytes; and server identity, meaning a trusted authority vouches that the machine you connected to really is the one that owns cityguide.example. That is the whole guarantee. TLS does not know or care who you are — it never authenticates the user, never checks a password, never decides what you are allowed to see. Those are your job, in your application code, in Module 07. A padlock in the browser means "this pipe is private and points where it claims to", not "this site is safe" and certainly not "you are logged in".

  • The four failures are diagnosable by name. "Could not resolve host" is DNS — the name is wrong. "Connection refused" is TCP — the name resolved, the machine answered, and nothing is listening on that port (usually: you forgot to start your server). "Certificate verify failed" is TLS. A 4xx or 5xx is HTTP — everything below worked perfectly and the application said no.
  • localhost short-circuits step 1. It resolves to 127.0.0.1 (or ::1 on IPv6) from a file on your own machine, without touching the network. That is why every command in this module works on a plane.
  • The port is the address of a program, not a machine. One computer, sixty-five thousand ports, a different program listening on each. Postgres took 5432 in Module 00; your API will take 8000. "Port already allocated" means two programs wanted the same one.
  • Steps 1–3 happen once and get reused. Opening a TCP connection and doing a TLS handshake is expensive, so clients keep the connection alive and send many HTTP requests down the same pipe. This is invisible to you and worth knowing exists.

The raw request and response

This is the part that turns a framework from magic into a program. Underneath every API you will ever write is a block of plain text.

What
An HTTP message is human-readable text: a first line, some Name: value headers, one blank line, then an optional body.
Where
On the wire, inside curl -v output, in your browser's network tab, and in the buffer your web framework parses.
When
Any time behaviour surprises you. Reading the actual bytes settles arguments that hours of guessing will not.
How
Look at both messages below until the four-part shape is obvious. It never varies.

Here is a real request, exactly as it travels — nothing is abbreviated:

POST /places HTTP/1.1                 <- request line: METHOD  PATH  VERSION
Host: localhost:8000                  <- headers start
Content-Type: application/json
Content-Length: 54
Accept: application/json
                                      <- ONE BLANK LINE. this is structural.
{"name": "Blue Bottle", "neighbourhood": "Mission"}
                                      <- body: 54 bytes, exactly as promised

And the response it gets back — same four parts, different first line:

HTTP/1.1 201 Created                  <- status line: VERSION  CODE  REASON
Content-Type: application/json        <- headers start
Content-Length: 61
Location: /places/42
                                      <- the same blank line, same purpose
{"id": 42, "name": "Blue Bottle", "neighbourhood": "Mission"}

Why the blank line exists, since a Prove it question will ask you. The server reads bytes off a socket one at a time. It has no idea in advance how many headers are coming — there could be three or thirty. So the protocol needs an unambiguous marker meaning "headers are finished". A blank line cannot occur inside a header (every header has a colon in it), so it is a safe terminator. And the body cannot use the same trick, because a body might legitimately contain blank lines — which is exactly why Content-Length exists: it tells the reader precisely how many more bytes to consume before stopping.

Line endings are \r\n, not \n. You will never type this by hand except once, at the end of this module, and it matters there.

  • Every web framework on earth is a text parser plus a router. FastAPI, Express, Django, Rails, Spring — all of them read that block, split the request line, build a dictionary of headers, decode the body, look up which of your functions matches the path, call it, then write another block of text back. That is the whole job. Module 02 puts a name on your function; nothing below that name changes.
  • The request line carries the two things routing needs. Method and path. GET /places and POST /places are different endpoints despite the identical path, and that pair is the key your framework looks up in its route table.
  • Headers are metadata; the body is the payload. "Who am I, what format am I sending, what do I want back" go in headers. The actual place you are creating goes in the body. Beginners jam data into headers and credentials into bodies; both are backwards.
  • GET requests normally have no body at all. The blank line is still there — the message just ends immediately after it. Anything you want to send with a GET goes in the query string.

Methods and status codes

Two small vocabularies. Learn these properly now and you will spend the rest of the track designing APIs rather than second-guessing them.

What
The method states your intent; the status code states the outcome. Both are agreed conventions, not enforced rules.
Where
The first line of every request and every response, respectively.
When
Every endpoint you design, forever. Choosing wrong is the most common flaw in a beginner's API and the easiest to fix.
How
Five methods, ten status codes. That is genuinely the whole working set — do not memorise the other eighty.

Two words come attached to the method table, and neither means what its English suggests. Safe means the request is not intended to change anything on the server — it only reads. Idempotent means sending it five times leaves the server in exactly the same state as sending it once. Idempotent does not mean "returns the same answer"; it means "does not stack up".

MethodMeansBody?SafeIdempotent
GETGive me this thing. Change nothing.NoYesYes
POSTCreate a new thing under this collection.YesNoNo
PUTReplace this thing entirely with what I send.YesNoYes
PATCHChange these specific fields of this thing.YesNoNot necessarily
DELETERemove this thing.NoNoYes

Why idempotency is not trivia. A client whose connection drops mid-request does not know whether the server got it. If the method is idempotent, retrying is free. If it is not, retrying a POST /places gives you two identical coffee shops in your database — and every double-charged payment you have ever read about is this exact bug. DELETE twice is fine: the second one finds nothing to delete and the end state is identical either way.

Now the outcomes. Status codes come in five families, and the family alone tells you who is at fault:

FamilyMeaningWhose problem
2xxIt worked.Nobody's
3xxIt lives somewhere else; go there.Nobody's
4xxYour request was wrong. Do not retry unchanged.The client's
5xxYour request was fine; I broke.The server's

The ten you will actually use:

CodeNameSend it when
200OKIt worked and there is a body to return.
201CreatedA POST made something new. Include a Location header pointing at it.
204No ContentIt worked and there is deliberately nothing to send back — usually a DELETE.
400Bad RequestThe request is malformed — you cannot even parse it.
401UnauthorizedI do not know who you are. Credentials missing, expired, or invalid.
403ForbiddenI know exactly who you are, and you are not allowed to do this.
404Not FoundThe URL is well-formed and names nothing that exists.
409ConflictValid request, but it clashes with current state — that email is already registered.
422Unprocessable EntityI parsed it perfectly; the values are wrong. Rating of 11 on a 1–5 scale.
500Internal Server ErrorYour code raised an exception. Always a bug, never the client's fault.

Four distinctions beginners get wrong. Read these twice; they come back in the Prove it section and in every code review you will ever receive.

  • 401 versus 403 — identity versus permission. 401 says "I do not know you, try again with credentials". 403 says "I know you, the answer is still no, trying again will not help". Sending 403 to a user who simply forgot to log in sends them chasing a permissions problem that does not exist.
  • 400 versus 422 — unparseable versus invalid. {"name": "Blue Bottle" with the closing brace missing is 400: it is not JSON, there is nothing to inspect. {"rating": 11} is perfect JSON and 422: it parsed fine, but 11 is not a rating. Module 06 makes FastAPI produce 422 automatically, and you will understand why only if you know the difference now.
  • 404 versus 400 — the address versus the letter. GET /places/9999 where place 9999 does not exist is 404 — the request was flawless, the thing is simply absent. GET /places/banana where the id must be an integer is 400 or 422 — the request itself is malformed and no place could ever have that id.
  • 500 means you have a bug, always. A 500 is never the client's fault by definition. If a client can make your server return 500 by sending odd input, the correct fix is to detect that input and return a 4xx — not to make the 500 message friendlier. Module 06 spends most of its length on exactly this.
  • The reason phrase is decoration. 200 OK, 200 Fine, 200 Groovy — every client on earth reads the number and ignores the words. Never write code that matches on the text.
In other stacks: nothing in this section is Python. Returning a 404 is res.status(404).json(...) in Express, HttpResponseNotFound() in Django, head :not_found in Rails, and w.WriteHeader(http.StatusNotFound) in Go — four spellings that put the identical three bytes on the wire. HTTP is the layer every backend language agrees on, which is why this module is the most transferable one in the whole track. Learn it once; it survives every framework you abandon.

Headers, content types, and JSON

What
Headers are Name: value metadata about the message; the body is raw bytes whose meaning one of those headers declares.
Where
Between the first line and the blank line of every HTTP message, in both directions.
When
Any time the two sides disagree about format, encoding, or who you are — which is most of the time when something is broken.
How
Three headers do almost all the work in an API. Learn those three and look up the rest.
HeaderDirectionSays
Content-TypeBoth"The body I am sending is in this format." application/json, text/html, image/png.
AcceptRequest"Send the response back in this format, please." A preference, not a command.
AuthorizationRequest"Here is who I am." Almost always Bearer <token>. Module 07 fills this in.
Content-LengthBoth"The body is exactly this many bytes." How the reader knows when to stop.

Here is the thing nobody says out loud: the body of an HTTP message is bytes. Not a dictionary, not an object, not JSON. A flat run of bytes with no inherent meaning. Content-Type: application/json is a label on those bytes, and the receiver takes it on trust. Send JSON labelled as text/plain and a strict server will refuse to parse it even though the bytes are perfect. Send garbage labelled as application/json and the server will try to parse it and fail with a 400. The label and the bytes are two independent things, and mismatches between them are a genuinely common bug.

JSON itself is small enough to describe completely. Six types: string, number, boolean, null, array, object. That is all of them.

{
  "id": 42,
  "name": "Blue Bottle",
  "rating": 4.5,
  "verified": true,
  "closed_at": null,
  "tags": ["coffee", "wifi"],
  "hours": { "open": "07:00", "close": "18:00" }
}

What JSON does not have, and what that costs you:

  • No date type. None. Every timestamp you ever send is a string, and the format is a convention you and the client must agree on. Use ISO 8601 — "2026-03-14T09:30:00Z" — because it sorts correctly as plain text and every language can parse it. "14/03/2026" is ambiguous to half the planet.
  • No comments. Not in the spec, not tolerated by most parsers. This is why JSON is a poor config format and a fine wire format — and why your project keeps config in .env instead.
  • Numbers are floats. JSON has one number type and most parsers map it to a 64-bit float, so integers above about 9 quadrillion silently lose precision. Real APIs send large ids as strings for exactly this reason. It bites people who use database ids or timestamps in nanoseconds.
  • Object keys are always strings. {1: "a"} is not JSON. Serialise a Python dict keyed by integers and they come back as "1" — a small surprise that will find you in Module 06.
  • No trailing commas, and double quotes only. {'name': 'x',} is valid Python and invalid JSON. Nearly every hand-written 400 you cause in this track is one of these two.
JSON is a convention, not a requirement. HTTP does not know or care what is in a body — it moves bytes. The web agreed on JSON for APIs because it is readable, has libraries everywhere, and is good enough. Plenty of production systems send Protocol Buffers, MessagePack, or CSV over the identical HTTP you learned above, changing nothing but Content-Type. Keeping "the transport" and "the format" separate in your head is what lets you read those systems later without relearning anything.

Doing it by hand with curl

Reading about HTTP does not stick. Sending one does. Everything below runs against a server built into Python — no install, no account, no network.

What
curl is a command-line HTTP client that shows you the exact bytes going out and coming back.
Where
Already installed on macOS, Linux, and WSL. It is the universal way to describe an API request in text.
When
Debugging anything HTTP-shaped, and every "is my server up?" check from here to Module 12.
How
Six flags. You will use -i and -v daily and the rest occasionally.
FlagDoes
-vVerbose. Shows the whole exchange — > lines are what you sent, < lines are what came back, * lines are curl's own notes about DNS and the connection.
-iInclude the response headers in the output, above the body. Quieter than -v; usually what you want.
-X POSTSet the method explicitly. Default is GET.
-H 'Name: value'Add a request header. Repeatable.
-d '<body>'Send a request body. Implies -X POST, so you rarely need both.
-o fileWrite the body to a file instead of the screen. -s silences the progress meter.

You need something to talk to. Python ships one — http.server serves the files in whatever folder you run it from, and it needs no installation at all:

mkdir -p ~/http-lab && cd ~/http-lab

cat > place.json <<'EOF'
{"id": 1, "name": "Blue Bottle Coffee", "neighbourhood": "Mission", "rating": 5}
EOF

python3 -m http.server 8000        # blocks. leave it running.
                                   # Serving HTTP on :: port 8000 ...

Open a second terminal — the first one is now occupied being a server. This is the shape of every day from here on: one terminal running the thing, one terminal poking it.

$ curl -i http://localhost:8000/place.json
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.12.1
Date: Sat, 14 Mar 2026 09:30:00 GMT
Content-type: application/json
Content-Length: 81
Last-Modified: Sat, 14 Mar 2026 09:29:41 GMT

{"id": 1, "name": "Blue Bottle Coffee", "neighbourhood": "Mission", "rating": 5}

Compare that against the response anatomy two sections up: status line, headers, blank line, body. Note it says HTTP/1.0 — Python's little server speaks the older version and closes the connection after each reply. Note also that Content-type: application/json was not configured by anyone; the server guessed it from the .json file extension. And notice what is missing: no Authorization, no cookies, nothing identifying you. Statelessness, visible.

-v shows the other half — the request you sent, which -i hides:

$ curl -v http://localhost:8000/place.json
* Trying [::1]:8000...
* Connected to localhost (::1) port 8000
> GET /place.json HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.0 200 OK
< Server: SimpleHTTP/0.6 Python/3.12.1
...

That lone > with nothing after it is the blank line ending your headers. You have now seen the structural rule from the previous section actually on the wire.

  • -d silently sets a Content-Type you did not ask for. Plain -d '{"a":1}' sends Content-Type: application/x-www-form-urlencoded — form data, not JSON. Servers that are strict about it will hand you a 400 while your JSON looks perfect. Always pair them: -H 'Content-Type: application/json' -d '...'.
  • Single-quote your URL if it has a query string. & means "run this in the background" to your shell, so curl http://x/?a=1&b=2 sends only ?a=1 and leaves you confused. Quote it: curl 'http://x/?a=1&b=2'.
  • curl exits 0 on a 404. From curl's point of view a 404 is a successful exchange — it asked, it got an answer. Use -o /dev/null -w '%{http_code}' when you want the number itself, which is how the Verify block below turns each check into a single unambiguous line.
  • The server's own terminal is a second log. Every request you send prints a line there: "GET /place.json HTTP/1.1" 200 -. Watching both terminals at once is the single fastest way to answer "did my request even arrive?" — and Module 10 makes your own server log like this deliberately.
python3 -m http.server serves the folder you started it in — every file in it, and every folder below. Run it in a scratch directory like ~/http-lab, never in your home folder and never in city-guide, where it would happily hand out your .env to anyone on your network. Ctrl+C stops it. It is a teaching tool and a file-sharing hack; it is not a web framework and never goes near a real deployment.

Build this

Target: a scratch folder ~/http-lab served by Python's built-in HTTP server, and a file notes/module-01.md in your city-guide repo containing four raw request/response transcripts you produced by hand — a success, a 404, a rejected method, and a full -v exchange with your own request headers visible. No code is written this module. The artefact is the transcript, and it is the thing you will re-read when Module 02 starts hiding all of this behind a decorator.
  1. Make ~/http-lab, put place.json in it, and start python3 -m http.server 8000. Leave it running in its own terminal.
  2. In a second terminal, fetch the file with curl -i. Copy the response into notes/module-01.md and annotate the four parts: status line, headers, blank line, body.
  3. Run the same request with -v. Paste the > lines into your notes — that is a request you sent without a browser. Name every header curl added on your behalf.
  4. Request /no-such-file.json. Record the status code and the body. Write one sentence on why this is a 404 and not a 400.
  5. Break it on purpose: send curl -i -X POST -H 'Content-Type: application/json' -d '{"name":"x"}' http://localhost:8000/place.json. You will not get 405 and you will not get 200. Record the code you actually got, read the message, and write down what it tells you about what this server was built to do.
  6. Commit notes/module-01.md. Then stop the server with Ctrl+C and re-run one curl command, so you see what step 2 of the four-step sequence fails like.

Verify it yourself

Start the server in ~/http-lab first, then run every command below in a second terminal. Each one is pass or fail with no interpretation needed. Your Python version and the dates will differ from these; nothing else should.

$ cd ~/http-lab && ls
place.json                              # the one file you are serving

# --- in terminal 1, leave this running ---
$ python3 -m http.server 8000
Serving HTTP on :: port 8000 (http://[::]:8000/) ...

# --- everything below runs in terminal 2 ---

$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/place.json
200                                     # the file exists and was served

$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/no-such-file.json
404                                     # well-formed URL, nothing there

$ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8000/place.json
501                                     # NOT 405. see Prove it question 5.

$ curl -s -o /dev/null -w '%{content_type}\n' http://localhost:8000/place.json
application/json                        # guessed from the .json extension

$ curl -si http://localhost:8000/place.json | head -2
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.x       # your Python version here

$ curl -s -H 'Accept: text/plain' -o /dev/null -w '%{content_type}\n' http://localhost:8000/place.json
application/json                        # Accept is a REQUEST, not a command

$ printf 'GET /place.json HTTP/1.1\r\nHost: localhost\r\n\r\n' | nc localhost 8000 | head -1
HTTP/1.0 200 OK                         # you just spoke HTTP with no HTTP client

$ curl -s -o saved.json http://localhost:8000/place.json && wc -c < saved.json
      81                                # -o wrote the body to disk

# --- now the deliberate break: stop the server with Ctrl+C, then ---

$ curl -s http://localhost:8000/place.json; echo "exit=$?"
exit=7                                  # 7 = could not connect. step 2, TCP.
                                        # the message wording varies by platform;
                                        # the exit code does not.

$ curl -s http://not-a-real-host.invalid/; echo "exit=$?"
exit=6                                  # 6 = could not resolve host. step 1, DNS.
Those last two are the point of the whole module. Neither of them has a status code, because no HTTP ever happened — the failure was underneath. Being able to say "this is a DNS problem, not a server problem" in the first five seconds is a skill that will separate you from people who have been writing endpoints for a year.

Prove it

Write your answer to each in notes/module-01.md in your own words before opening the answer. Where you disagree with the answer, work out which of you is right — that is where the learning lives.

1. You request /places/9999 and no place with id 9999 exists. Why is that a 404 and not a 400?

Show answer

Because nothing about the request was wrong. It was well-formed, the path matched a real route, 9999 is a perfectly legal integer id — the server understood every byte of it. It simply looked and found nothing. 400 means "I cannot make sense of what you sent"; there is nothing to fix in this request, so telling the client it was malformed sends them hunting for a typo that does not exist. Compare /places/banana: the id must be an integer, so the request itself is defective and no place could ever have that id — that is a 400 or 422. The rule that survives every edge case: 4xx codes describe the request, and 404 is the one that says the request was fine and the world is empty.

2. HTTP is stateless. What does that actually cost you, and what do you get in exchange?

Show answer

It costs you identity. The server retains nothing between requests, so "I logged in two seconds ago" is not a fact the server has access to — every subsequent request must carry proof of who you are, all over again, or be treated as an anonymous stranger. That is the entire reason cookies, sessions, and bearer tokens exist: they are workarounds for a protocol that deliberately forgets. What you get in exchange is enormous: because no request depends on which machine handled the last one, you can put ten identical servers behind a load balancer and any of them can answer anything. Scaling becomes an operations decision instead of a rewrite. Module 07 builds the token that patches the hole, and Module 08 splits the signing of it into its own service.

3. Your site is on HTTPS and the browser shows a padlock. What is actually guaranteed, and what is not?

Show answer

Guaranteed: confidentiality and integrity — nobody between your machine and the server can read or tamper with the bytes; and server identity — a certificate authority vouches that the machine you reached genuinely controls that domain name. That is the complete list. Not guaranteed: anything about you. TLS never authenticates the user, never checks a password, never decides what you are allowed to see. It also says nothing about whether the site is trustworthy, competently written, or storing your data safely — a phishing site can get a valid certificate in minutes, and its padlock is entirely real. The padlock means "this pipe is private and points where it claims to". Everything about permissions and identity happens above TLS, in your application code, which is why Module 07 exists at all.

4. Why does an HTTP message need a blank line between the headers and the body?

Show answer

Because the reader is consuming bytes off a socket one line at a time and does not know in advance how many headers are coming — there may be three or thirty, and they can appear in any order. The protocol therefore needs an unambiguous marker for "headers are finished". A blank line works because a valid header always contains a colon, so an empty line can never be one. The body cannot use that trick, since a body might legitimately contain blank lines of its own — which is precisely why Content-Length exists: after the blank line, the reader consumes exactly that many bytes and stops. Take away the blank line and a parser cannot tell where metadata ends and payload begins; take away Content-Length and it cannot tell where the message ends at all.

5. You sent a POST to python3 -m http.server and got 501, not 405. What is the difference, and which would your own API send?

Show answer

405 Method Not Allowed means "I know this method, and it is not permitted on this particular resource" — a 4xx, the client's problem. 501 Not Implemented means "I do not support this method at all, anywhere, for anyone" — a 5xx, the server's limitation. Python's http.server implements exactly two verbs, GET and HEAD, so POST is not a rule it is enforcing per-path; it is a capability it does not have. 501 is the honest answer. Your own API in Module 02 will be different: it will genuinely implement POST, so a POST to a read-only path is a 405, and it should also send an Allow: GET, HEAD header telling the client what it could have used instead. The family digit is doing real work here — 4xx tells the client to change something, 5xx tells them not to bother.

6. You POST a new place, the connection times out, and you have no idea whether it worked. What do you do, and how would a different method have changed the answer?

Show answer

You cannot safely retry. POST is neither safe nor idempotent, so the request may have created the place and died on the way back — retrying gives you two identical coffee shops. The correct move is to GET the collection and check before doing anything else. Had this been a PUT /places/42, retrying would have been free: PUT replaces a resource with the body you send, so doing it five times leaves exactly the same state as doing it once. This is the entire practical value of idempotency — it decides whether a client is allowed to retry blindly. Real systems that must keep POST solve it with an idempotency key: the client generates a unique id, sends it as a header, and the server remembers it and returns the original result instead of creating a second record. Every double-charged card you have read about is this problem, unsolved.