Request & response anatomy
- Read a raw HTTP request and response: request line, status line, header fields, the empty line and the body
- Classify a method as safe and/or idempotent (RFC 9110), and say which failed requests a client may resend on its own
- Tell
Content-TypefromAccept, and handle a response by its status class —fetchresolves on a 404 or a 500 - Make a
POSTretry-safe with oneIdempotency-Keyper operation, retrying only failures that can succeed later
Read
A request is text you can read
FlagStrip away the client library and an HTTP/1.1 request is a few lines of text. This is exactly
what curl sent to a local API (captured with curl -v, which the next unit covers):
POST /orders HTTP/1.1
Host: localhost:4123
User-Agent: curl/8.7.1
Content-Type: application/json
Accept: application/json
Content-Length: 23
{"sku":"TEA-1","qty":2}
Four parts, always in this order (RFC 9112, section 2.1):
- The request line —
method SP target SP version.POSTis what to do,/ordersis what to do it to,HTTP/1.1is the protocol. - Header fields — one
Name: valueper line. Metadata about the request: who the host is, what the body is, what the client wants back. - An empty line — the only thing that says "headers are over".
- The body (optional) — exactly
Content-Lengthbytes of data. AGETusually has none.
The response has the same shape; only the first line differs:
HTTP/1.1 201 Created
Content-Type: application/json
Content-Length: 32
Location: /orders/43
Date: Sat, 17 Oct 2026 06:45:44 GMT
{"id":43,"sku":"TEA-1","qty":2}
The status line carries the version, a three-digit status code and a reason phrase.
Software reads the code; the phrase is for humans and may be anything. 201 Created plus a
Location header is how a server says "I made a new thing, and here's its URL" (RFC 9110,
section 9.3.3).
Three habits this gives you:
- When an API "ignores your body", look for the missing or wrong
Content-Typebefore you look at the server code. - Header names are case-insensitive (
content-typeandContent-Typeare the same field); header values often aren't. - Debugging HTTP means reading these lines. Everything else —
fetch, axios, a NestJS controller — builds or parses them for you.
Safe, idempotent, and what a client may resend on its own
FlagTwo properties of a method decide what browsers, proxies, prefetchers and retry loops are allowed to do with your request. RFC 9110 (section 9.2) defines both.
Safe — the client asks for no state change. A crawler, a link prefetcher or a cache may send it without fear. Idempotent — sending it N times has the same intended effect on the server as sending it once.
| Method | Safe | Idempotent | Why |
|---|---|---|---|
GET, HEAD, OPTIONS | yes | yes | read-only, so repeating changes nothing |
PUT | no | yes | "make the resource equal to this" — the second PUT finds it already equal |
DELETE | no | yes | the second DELETE finds it already gone |
POST | no | no | "process this" — two calls can mean two orders |
PATCH | no | no | RFC 5789: depends on the patch (set note repeats fine, increment qty doesn't) |
Every safe method is idempotent; not every idempotent method is safe.
Idempotent is about the effect, not the response
DELETE /orders/7 -> 204 No Content
DELETE /orders/7 -> 404 Not Found
Different responses, same effect: order 7 doesn't exist. RFC 9110 says so explicitly — the repeat has "the same intended effect, even if the original request succeeded, though the response might differ". Logging, metrics and audit rows don't count against idempotency either; only what the client asked for does.
Why it matters: retries after a lost response
The dangerous moment is a request sent and no response received — a timeout, a dropped connection. Did the server do it? The client can't know. RFC 9110 draws the line:
- An idempotent request "can be repeated automatically" after a communication failure: if the first one landed, the second changes nothing.
- A client SHOULD NOT automatically retry a non-idempotent request unless it has some way to know it's safe. A proxy MUST NOT.
So a retry helper that wraps every call in "try three times" is fine for GET and PUT and a
double-charge bug for POST /payments.
Safe is a promise you make as a server
GET /emails/42?action=archive
If that archives the email, every link checker, preview bot and prefetcher that follows the
URL archives it too. RFC 9110 puts this on the resource owner: an unsafe action reached through a
safe method must be disabled. Use POST /emails/42/archive (or PATCH the email's state)
instead.
Making a POST retry-safe with an Idempotency-Key
FlagPOST /payments times out. Three different things may have happened, and the client can't tell
them apart:
- The request never reached the server.
- The server charged the card, and the response was lost on the way back.
- The server is still working on it.
A 502 or 504 from a gateway is the same uncertainty in status-code form: the proxy gave up
on the upstream, but the upstream may have finished the work. Retrying blindly turns outcome 2
into a double charge; not retrying turns outcome 1 into a lost order.
One key per operation, reused on every attempt
The fix is to make the POST recognisable. The client generates a unique key for the
operation — the user's one click on "Pay" — and sends it on the first attempt and on every
retry of that operation:
async function createPayment(input: PaymentInput, send: typeof fetch) {
const key = crypto.randomUUID(); // ONCE, outside the loop
for (let attempt = 1; ; attempt++) {
try {
const res = await send("/payments", {
method: "POST",
headers: { "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(input),
});
if (!RETRYABLE.has(res.status) || attempt === MAX_ATTEMPTS) return res;
await sleep(delayFor(res, attempt)); // honour Retry-After if present
} catch (err) {
if (attempt === MAX_ATTEMPTS) throw err; // no response: the same key makes a resend safe
await sleep(delayFor(null, attempt));
}
}
}
On the server, the key is a lookup before any work: first time seen → do the work and store the
response under the key; seen and finished → return the stored response without doing anything;
seen and still running → refuse with 409 Conflict; same key with a different body →
422, because the client is misusing the key. Keys expire after a documented window.
Generate the key inside the loop and every attempt looks like a brand-new payment — the header is present, and it protects nothing.
What's worth retrying at all
| Outcome | Retry? |
|---|---|
| No response (network error, timeout) | Yes — idempotent methods as-is, POST/PATCH only with a key |
429, 503 | Yes, after the server's Retry-After if it sent one |
502, 504 | Yes, under the same rules as "no response" |
408 | Yes — the server says it never got the whole request |
Other 4xx | No — the same bytes get the same answer |
500 | A policy decision: it's "unexpected", not "temporary" |
Retry-After is either a number of seconds or an HTTP date (RFC 9110, section 10.2.3). When the
server sends one, it knows more than your exponential backoff does.
Quiz
In a raw HTTP/1.1 request, how does the receiver know where the header fields end and the body begins?
FlagYour client sends a JSON body to POST /reports and wants the report back as CSV. Which headers say that?
Your POST gets back status 471, a code you've never seen. Per RFC 9110, how should a client treat it?
DELETE /orders/7 returns 204. A retry of the same request returns 404. Is DELETE still idempotent?
The connection drops before any response arrives. Which request may an HTTP client library resend automatically, with no other information?
FlagGET /emails/42?action=archive archives the email. A chat app's link-preview bot fetches pasted URLs, and users' emails start disappearing. Whose bug is it?
A retry loop runs const key = crypto.randomUUID() inside the loop, then sends POST /payments with Idempotency-Key: key. The first attempt charges the card but its response is lost; the second succeeds. How many charges?
Your server completed Idempotency-Key: k1 for {"amount": 40}. Now k1 arrives again with {"amount": 50}. What should it do?
Fill in the blank
Write the request line's method, and the header that asks for JSON back.
Flag/orders/42 HTTP/1.1 Host: api.example.com : application/json
The server created order 43. Fill in the status code and the header that gives the new resource's URL.
FlagHTTP/1.1 Created Content-Type: application/json : /orders/43 {"id":43,"sku":"TEA-1"}
Without this header, fetch labels the string body text/plain;charset=UTF-8 and a JSON body parser skips it.
await fetch("/api/orders", { method: "POST", headers: { "": "application/json" }, body: JSON.stringify(order), });
fetch resolved with a 500. Turn that into an error.
const res = await fetch("/api/orders"); if (!res.) throw new Error(`HTTP ${res.status}`);
Make the retry safe, and wait as long as the server asked.
Flagconst key = crypto.randomUUID(); // once per operation for (let attempt = 1; attempt <= 3; attempt++) { const res = await fetch("/payments", { method: "POST", headers: { "Content-Type": "application/json", "": key }, body, }); if (res.status !== 503) return res; await sleep(Number(res.headers.get("") ?? 1) * 1000); }
Predict the output
// Server: Express 5
app.use(express.json());
app.post("/api/orders", (req, res) => res.json({ got: req.body }));
// Client
const res = await fetch("/api/orders", {
method: "POST",
body: JSON.stringify({ sku: "TEA-1" }),
});
console.log(await res.text());// A hand-rolled Node server
const body = JSON.stringify({ name: "café" });
res.writeHead(200, {
"Content-Type": "application/json",
"Content-Length": body.length,
});
res.end(body);
// Client
const text = await (await fetch(url)).text();Challenge
Make the retry helper safe for POST
FlagsendWithRetry wraps every API call in the app. It was written for GETs, and it "works" —
until checkout started using it. Now a flaky connection during payment sometimes charges the
card twice, a 422 for a bad card number is sent three times before the user sees it, and the
payment provider's rate limiter keeps banning us because we ignore its Retry-After.
The rules it should follow (RFC 9110 for which methods are idempotent, the Idempotency-Key
convention for the rest):
GET,HEAD,OPTIONS,PUTandDELETEare idempotent: resend them as they are.POSTandPATCHare not. Resend them only with anidempotency-keyheader — the same key on every attempt of one call, so the server can recognise the retry. If the caller already set one (say, one key per checkout), keep it.- Retry when there was no response at all (
sendrejected), or the status means "later":408,429,502,503,504. Everything else is the final answer — including500, which this API uses for bugs, not for overload. - Before a retry, wait
retry-afterseconds if the response has a numeric one; otherwisebaseDelayMs,2 × baseDelayMs,4 × baseDelayMs, … (Retry-Aftermay also be an HTTP date; this helper doesn't parse dates and treats one like a missing header.)
http.ts holds the types. Header names are lowercase throughout.
Your task
Edit send-with-retry.ts so the tests pass:
- Every attempt of one
POSTcarries the sameidempotency-key, made once withnewKey() - A
PATCHgets a key too;GET,HEAD,OPTIONS,PUTandDELETEare retried without one - A key the caller already set is kept, not replaced
- Only a dropped connection,
408,429,502,503and504are retried; any other status is returned after one attempt - A numeric
retry-after(seconds) sets the wait; otherwise the wait isbaseDelayMs, then double each time - After
maxAttempts, the last response is returned or the last network error is rethrown
Further reading
- RFC 9110: HTTP Semantics
- RFC 9112: HTTP/1.1 (message format)
- RFC 5789: PATCH Method for HTTP
- RFC 6585: Additional HTTP Status Codes (429)
- Fetch Standard: extracting a body (default Content-Type)
- MDN: Window.fetch()
- Express 5 API: express.json()
- IETF draft: The Idempotency-Key HTTP Header Field
- Stripe API: Idempotent requests