HTTP: The Protocol You Speak Daily
You have written @GetMapping and @PostMapping dozens of times, and Spring has politely hidden what those annotations actually produce: a few lines of plain text on a TCP connection. This module strips the framework away. By the end, an HTTP request is something you could type by hand — and in Build This, you effectively will.
The Goal
By the end of this module you can:
- Write a raw HTTP request from memory: request line, headers, blank line, body
- Explain the contract behind each method — safe vs idempotent — and why it decides whether a retried payment charges once or twice
- Sort any status code into the right bucket in four lines, and say whose fault it is
- Justify the six headers that show up in every API you will ever build
- Say what keep-alive saves, and what actually changed in HTTP/2 and HTTP/3
- Read every line of
curl -voutput against your ownsplitease-apiwithout guessing
The Lesson
HTTP is text
The biggest demystification in this whole track: an HTTP/1.1 request is a few lines of plain text. This is a complete, valid request:
GET /actuator/health HTTP/1.1
Host: localhost:8080
That is it. If you opened a raw TCP connection to your running splitease-api and typed those lines (plus one empty line), Spring would answer. No binary format, no framework, no magic — text in, text out, over the TCP pipe that module 02 spent three packets setting up.
This matters because every HTTP tool you will ever use — curl, DevTools, Postman, Spring’s logs — is just showing you this text with different decoration. Learn the text once and every tool becomes readable.
Anatomy, precisely
A request has four parts, in this exact order:
| Part | What it is | Example |
|---|---|---|
| Request line | METHOD path VERSION | POST /api/v1/expenses HTTP/1.1 |
| Headers | One Key: value per line | Content-Type: application/json |
| Blank line | An empty line. Nothing on it. | |
| Body | Optional payload | {"amountPaise":4000} |
The blank line is the part everyone underestimates. It is the only signal that says “headers are finished — what follows is body.” Forget it when hand-writing a request and the server waits forever, because as far as it knows you are still sending headers. It is one empty line carrying the entire structure of the protocol.
The response mirrors the request:
| Part | What it is | Example |
|---|---|---|
| Status line | VERSION code reason | HTTP/1.1 201 Created |
| Headers | Same Key: value format | Location: /api/v1/expenses/42 |
| Blank line | Same job: head ends here | |
| Body | The payload | your JSON |
One head, one blank line, one body — in both directions. That symmetry is the whole protocol.
Methods are contracts, not just verbs
You know what GET and POST “mean.” What matters in production is what they promise:
| Method | Safe (no state change)? | Idempotent (N calls = 1 call)? | The contract |
|---|---|---|---|
| GET | Yes | Yes | Read. Never changes anything. Cacheable. |
| HEAD | Yes | Yes | GET without the body — headers only. |
| PUT | No | Yes | Replace the resource. Doing it twice leaves the same state. |
| DELETE | No | Yes | Remove it. Removing it again changes nothing further. |
| POST | No | No | Create / act. Doing it twice does it twice. |
| PATCH | No | Not guaranteed | Partial update. Depends on what the patch says. |
Safe means the server’s state does not change — you can call it freely. Idempotent means calling it five times leaves the server in the same state as calling it once.
Here is why this table is fintech-critical and not academic. A mobile app sends POST /payments, the response times out (the request may or may not have arrived — module 02 showed you why a timeout proves nothing), and the app retries. POST is not idempotent: if both requests landed, the user paid twice. The standard fix is an idempotency key — the client sends a unique id per logical payment, the server stores it, and a retry with a seen key returns the original result instead of charging again. That converts a POST into something idempotent by contract.
You already met this judgement call in the SplitEase capstone: settle is a GET precisely because it computes who owes whom without moving money — safe and idempotent, so retries and caches are harmless. Recording a real payment would be a POST, and would need the key treatment above.
Status codes: a decision tree, not a memorization list
The first digit tells you whose problem it is. Four lines:
- 2xx — it worked. Read the body.
- 3xx — it lives elsewhere. Follow
Location. - 4xx — you (the client) sent something wrong. Fix the request; retrying unchanged is pointless.
- 5xx — the server failed. Your request may have been fine; retrying can make sense.
flowchart TD
A["Response arrives"] --> B{"First digit"}
B -->|"2"| C["Worked - read the body"]
B -->|"3"| D["Elsewhere - follow Location"]
B -->|"4"| E["Client fault - fix the request"]
B -->|"5"| F["Server fault - retry may help"]
The specific codes worth knowing cold — 200/201/204, 301/304, 400/401/403/404/409/429, 500/502/503 — are tabled in week 3; this module will not re-list them. What week 3 cannot give you is the reflex: first digit → whose fault → what to do. A 4xx in your logs means go read the client code (or the request someone sent you). A 5xx means go read your own stack trace. That single sort saves hours of looking in the wrong place.
Six headers that earn their place
Hundreds of headers exist. These six carry almost all of your working life, and you have already built against every one of them:
| Header | One line | Where you built it |
|---|---|---|
Content-Type | ”The body I am sending is this format” — how Spring picks a converter for @RequestBody | REST controllers |
Accept | ”The body I want back is this format” — content negotiation, the request-side twin of Content-Type | same |
Authorization | Bearer <token> — who is asking; read by your JWT filter before any controller runs | security module |
Location | On a 201, where the new resource lives — why created(...) takes a URI | REST controllers |
Cache-Control | How long anyone may keep a copy of this response | the CDN side |
X-Request-Id | One id stamped on a request so every log line about it can be grepped at 2am | capstone’s RequestIdFilter |
Headers are just key: value text lines, remember — there is nothing special about custom ones like X-Request-Id. You invented a header by writing ten lines of filter.
Keep-alive: one connection, many requests
Module 02 priced a TCP handshake at one full round trip before any data moves. If every HTTP request paid that price, APIs would crawl.
So HTTP/1.1 made keep-alive the default: after a response, the TCP connection stays open and the next request reuses it. One handshake, then request after request down the same pipe. This is exactly the economics behind connection pooling — HikariCP does the same trick with database connections, and module 06 shows your HTTP client doing it too.
The catch in HTTP/1.1: one connection handles one request at a time. Send request A, and request B waits until A’s response fully arrives — even if B would have been answered instantly. That queue-behind-the-slow-one problem is called head-of-line blocking, and it is the main thing later versions attack.
HTTP/1.1 vs 2 vs 3, honestly
| Version | What changed | What it means for you |
|---|---|---|
| HTTP/1.1 | Text protocol, keep-alive, one request at a time per connection | The version you can read by eye, the one your local Spring app speaks |
| HTTP/2 | Binary framing, many requests multiplexed over one connection | Head-of-line blocking at the HTTP layer gone; your code unchanged |
| HTTP/3 | Runs on QUIC over UDP instead of TCP; transport and TLS handshakes collapsed together | Faster setup, better on flaky mobile networks; your code still unchanged |
The honest summary: day to day this changes almost nothing for you — your platform or CDN negotiates the version, and your Spring annotations are identical under all three. In interviews it changes everything, because “what did HTTP/2 actually fix?” is a standard filter question. The answer is the table above, and the key phrase is multiplexing kills head-of-line blocking.
Polling cheaply: ETag and 304
One pattern ties content negotiation and caching together. The server sends a response with an ETag header — a fingerprint of the body. Next time, the client sends If-None-Match: <that fingerprint>. If nothing changed, the server replies 304 Not Modified with no body at all.
A dashboard polling your balances endpoint every 10 seconds mostly gets tiny 304s instead of full JSON payloads — the polling-cheaply pattern. You are not asked to implement it today; you are asked to recognize a 304 as “your cached copy is still good,” not as an error.
See It Move
Watch a real request and response assemble line by line — and watch what the blank line does.
Step through it and notice:
- The request line alone names the action — method, path, version — before any header exists.
- Headers are nothing but
key: valuetext lines stacked up;Authorizationis no more magical thanHost. - The blank line is the hinge of the whole exchange: the instant it appears, head ends and body begins. It is the star of this module for a reason.
- The response is the same shape in reverse — status line where the request line was, then headers, blank line, body.
Check The Concept
How This Shows Up At Work
- The 2am double-charge. A gateway timeout plus an auto-retrying client plus a non-idempotent POST equals two debits and one furious customer. The postmortem line “writes must be idempotent under retry” is this module’s method table, learned the expensive way.
- Code review. A teammate returns 200 with
{"error": "not found"}in the body. You flag it: clients, monitors, and caches all sort by status code first — a 200 that means failure poisons every layer above it. That review comment is the four-line rule doing work. - The interview filter. “What is the difference between PUT and POST?” is not asking for definitions — it is asking whether you say idempotent. Follow-up: “so how do you make a payment POST safe to retry?” Now you have the answer.
- Reading the wire under pressure. An integration partner says “your API is rejecting us.” You ask for the raw request, spot
Content-Type: text/plainabove a JSON body, and call the 415 in thirty seconds — because you can read the text, not just the framework.
Build This
One PowerShell note before you start: in Windows PowerShell, bare curl is an alias for Invoke-WebRequest. Type curl.exe to get real curl — it ships with Windows 10+.
- Start
splitease-api, then make the verbose call:
curl.exe -v http://localhost:8080/actuator/health
- Now annotate every line of the output. Yours will look close to this:
* Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> GET /actuator/health HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.9.1
> Accept: */*
>
< HTTP/1.1 200
< Content-Type: application/vnd.spring-boot.actuator.v3+json
< Transfer-Encoding: chunked
< Date: Thu, 12 Jun 2026 09:15:02 GMT
<
{"status":"UP"}* Connection #0 to host localhost left intact
Read it with the legend: * lines are curl narrating (TCP connect — module 02’s handshake happening before your eyes). > lines are bytes sent: the request line, then three headers curl added for you, then > on its own — the blank line. < lines are bytes received: the status line (Spring sends a bare 200, no reason phrase — legal), headers, another blank line, then the JSON body. The final * line says keep-alive kept the connection open for reuse.
- Prove headers are just text you control — send one and see it echoed back:
curl.exe -v -H "Accept: application/json" httpbin.org/headers
The response body is your own request headers reflected as JSON. Find your Accept there. Add -H "X-Request-Id: darshan-test-1" and run again — your invented header comes back too. Nothing is special; headers are lines.
- A POST with a body (the backslashes escape the inner quotes for PowerShell):
curl.exe -v -X POST httpbin.org/post -H "Content-Type: application/json" -d '{\"note\":\"chai\",\"amountPaise\":4000}'
In the > lines, find Content-Type, find Content-Length (curl computed it), find the blank line, and see the body go after it. httpbin echoes the parsed JSON back under "json" — proof the server understood the format you declared.
Now break it on purpose — three times:
- No Authorization. Call a protected endpoint of your API with no token:
curl.exe -v http://localhost:8080/api/v1/friends
Read the 401 anatomy: status line HTTP/1.1 401, a WWW-Authenticate header (the server telling you which auth scheme it expects), and little or no body. Your JWT filter decided this before any controller existed in the story.
- Wrong Content-Type. POST to your expenses endpoint with a JSON body but declare it as plain text (use a real token from login):
curl.exe -v -X POST http://localhost:8080/api/v1/groups/1/expenses -H "Authorization: Bearer <token>" -H "Content-Type: text/plain" -d '{\"description\":\"chai\"}'
Expect a 415 Unsupported Media Type (or a 400, depending on your handler): the body was fine, the label was wrong, and Spring refused to pick a converter. Whose fault per the four-line rule? Yours — 4xx, fix the request.
- A route that does not exist. With your token, GET
/api/v1/nothing-here. Read the 404 and compare its body to theApiErrorshape you built in services & transactions. And one subtlety: try the same URL without the token — you get 401, not 404, because the security filter runs before routing. The wire just taught you your own filter order.
Where to Practice
| Resource | What to do there | How long |
|---|---|---|
| httpbin.org | Hit /headers, /post, /status/418, /delay/3 with curl.exe -v and read every line | 25 min |
| MDN HTTP docs (developer.mozilla.org) | Read “An overview of HTTP” and the “HTTP request methods” page — confirm the safe/idempotent table from memory first | 30 min |
| curl docs (curl.se) | Skim “curl tutorial” sections on -v, -H, -d, -X so the flags you just used are yours | 15 min |
| Cloudflare Learning Center (cloudflare.com/learning) | Read the “What is HTTP/3?” article and check it against the version table above | 15 min |
Check Yourself
- Write a minimal valid GET request for
/actuator/healthfrom memory — every line, including the one with nothing on it. - What separates the head of an HTTP message from its body, and what happens if it is missing?
- Safe vs idempotent — define both, and name a method that is idempotent but not safe.
- Why does a timed-out POST plus an auto-retry risk a double charge, and what is the standard fix?
- The four-line rule: what does the first digit of a status code tell you, digit by digit?
- Which header says “the body I am sending is JSON,” and which says “the body I want back is JSON”?
- What does keep-alive save, and what limitation of HTTP/1.1 connections did HTTP/2 multiplexing remove?
- A client sends
If-None-Matchand receives 304 with no body. Is something broken?
Answers
GET /actuator/health HTTP/1.1, thenHost: localhost:8080, then a blank line. Three lines, the last one empty.- The blank line. Without it the server thinks headers are still coming and waits forever.
- Safe = no server state change at all (GET, HEAD). Idempotent = N calls leave the same state as 1 call. DELETE and PUT are idempotent but not safe — they change state, but repeating them changes nothing further.
- A timeout does not tell the client whether the request landed, and POST is not idempotent, so a retry may execute the action twice. Fix: an idempotency key — the server detects the repeated key and returns the original result instead of acting again.
- 2xx it worked; 3xx it is elsewhere, follow Location; 4xx the client sent something wrong, fix the request; 5xx the server failed, retrying may help.
Content-Typedescribes the body being sent;Acceptrequests the format of the response.- Keep-alive saves a TCP handshake (a full round trip) per request by reusing one connection. But an HTTP/1.1 connection serves one request at a time — head-of-line blocking — which HTTP/2 removed by multiplexing many streams over one connection.
- No — it is the cheap-polling pattern working. 304 Not Modified means “your cached copy is still current,” so the server skipped the body on purpose.
Explain it out loud: Talk through everything that crosses the wire when your phone’s UPI app confirms a payment — request line, the headers that matter and why, the blank line, the body, then the response coming back — and end with why that POST had better carry an idempotency key. If you stall on any line of the request, that is the section to re-read.
Still Unclear?
I can recite that PUT is idempotent and POST is not, but I do not feel it.
Give me 5 realistic API endpoints for an expense-splitting app and make me
choose the method for each, then challenge each choice with a retry scenario.
Do not reveal answers until I commit.
Here is the full output of curl -v against my own Spring Boot API: [paste].
Quiz me line by line — ask me what each line means before you explain it.
Flag any header in there I have not accounted for.
Explain head-of-line blocking in HTTP/1.1 and how HTTP/2 multiplexing fixes
it, using a single chai stall with one server and many customers as the only
analogy. Then ask me to explain what HTTP/3 changed and correct me.
Why AI Can’t Do This For You
AI will happily generate a controller with the wrong status codes and a non-idempotent payment endpoint, and it will look perfectly professional until the first client retry double-charges someone. Choosing the method contract, the status code, and the idempotency strategy is design judgement about your system’s failure modes — the model has no idea your gateway times out at 30 seconds or that your mobile app auto-retries.
And when a partner integration breaks in production, the evidence is raw wire text: a curl -v dump, a missing header, a blank line in the wrong place. The engineer who can read that text directly finds the bug while everyone else is still re-prompting. That reading skill is built exactly one annotated line at a time — which is what you just did.
Module done? Add it to today’s tracker