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.
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.
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.
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.
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.curl command, every requests.get() line you will ever write.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:
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:
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".
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.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.
Name: value headers, one blank line, then an optional body.curl -v output, in your browser's network tab, and in the buffer your web framework parses.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.
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.Two small vocabularies. Learn these properly now and you will spend the rest of the track designing APIs rather than second-guessing them.
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".
| Method | Means | Body? | Safe | Idempotent |
|---|---|---|---|---|
GET | Give me this thing. Change nothing. | No | Yes | Yes |
POST | Create a new thing under this collection. | Yes | No | No |
PUT | Replace this thing entirely with what I send. | Yes | No | Yes |
PATCH | Change these specific fields of this thing. | Yes | No | Not necessarily |
DELETE | Remove this thing. | No | No | Yes |
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:
| Family | Meaning | Whose problem |
|---|---|---|
2xx | It worked. | Nobody's |
3xx | It lives somewhere else; go there. | Nobody's |
4xx | Your request was wrong. Do not retry unchanged. | The client's |
5xx | Your request was fine; I broke. | The server's |
The ten you will actually use:
| Code | Name | Send it when |
|---|---|---|
200 | OK | It worked and there is a body to return. |
201 | Created | A POST made something new. Include a Location header pointing at it. |
204 | No Content | It worked and there is deliberately nothing to send back — usually a DELETE. |
400 | Bad Request | The request is malformed — you cannot even parse it. |
401 | Unauthorized | I do not know who you are. Credentials missing, expired, or invalid. |
403 | Forbidden | I know exactly who you are, and you are not allowed to do this. |
404 | Not Found | The URL is well-formed and names nothing that exists. |
409 | Conflict | Valid request, but it clashes with current state — that email is already registered. |
422 | Unprocessable Entity | I parsed it perfectly; the values are wrong. Rating of 11 on a 1–5 scale. |
500 | Internal Server Error | Your 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.
{"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.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.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.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.
Name: value metadata about the message; the body is raw bytes whose meaning one of those headers declares.| Header | Direction | Says |
|---|---|---|
Content-Type | Both | "The body I am sending is in this format." application/json, text/html, image/png. |
Accept | Request | "Send the response back in this format, please." A preference, not a command. |
Authorization | Request | "Here is who I am." Almost always Bearer <token>. Module 07 fills this in. |
Content-Length | Both | "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:
"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..env instead.{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.{'name': 'x',} is valid Python and invalid JSON. Nearly every hand-written 400 you cause in this track is one of these two.Content-Type. Keeping "the transport" and "the format" separate in your head is what lets you read those systems later without relearning anything.
Reading about HTTP does not stick. Sending one does. Everything below runs against a server built into Python — no install, no account, no network.
curl is a command-line HTTP client that shows you the exact bytes going out and coming back.-i and -v daily and the rest occasionally.| Flag | Does |
|---|---|
-v | Verbose. 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. |
-i | Include the response headers in the output, above the body. Quieter than -v; usually what you want. |
-X POST | Set 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 file | Write 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 '...'.& 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'.-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."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.
~/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.
~/http-lab, put place.json in it, and start python3 -m http.server 8000. Leave it running in its own terminal.curl -i. Copy the response into notes/module-01.md and annotate the four parts: status line, headers, blank line, body.-v. Paste the > lines into your notes — that is a request you sent without a browser. Name every header curl added on your behalf./no-such-file.json. Record the status code and the body. Write one sentence on why this is a 404 and not a 400.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.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.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.
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?
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?
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?
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?
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?
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?
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.