HTTP & APIs in Practice
HTTP & APIs in Practice

Request & response anatomy

Full · ~30 min
After this unit you can
  • 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-Type from Accept, and handle a response by its status class — fetch resolves on a 404 or a 500
  • Make a POST retry-safe with one Idempotency-Key per operation, retrying only failures that can succeed later

Read

Simple
1 / 2

A request is text you can read

Flag

Strip 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):

  1. The request line — method SP target SP version. POST is what to do, /orders is what to do it to, HTTP/1.1 is the protocol.
  2. Header fields — one Name: value per line. Metadata about the request: who the host is, what the body is, what the client wants back.
  3. An empty line — the only thing that says "headers are over".
  4. The body (optional) — exactly Content-Length bytes of data. A GET usually 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-Type before you look at the server code.
  • Header names are case-insensitive (content-type and Content-Type are 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.

Medium
1 / 2

Safe, idempotent, and what a client may resend on its own

Flag

Two 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.

MethodSafeIdempotentWhy
GET, HEAD, OPTIONSyesyesread-only, so repeating changes nothing
PUTnoyes"make the resource equal to this" — the second PUT finds it already equal
DELETEnoyesthe second DELETE finds it already gone
POSTnono"process this" — two calls can mean two orders
PATCHnonoRFC 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.

Complex

Making a POST retry-safe with an Idempotency-Key

Flag

POST /payments times out. Three different things may have happened, and the client can't tell them apart:

  1. The request never reached the server.
  2. The server charged the card, and the response was lost on the way back.
  3. 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

OutcomeRetry?
No response (network error, timeout)Yes — idempotent methods as-is, POST/PATCH only with a key
429, 503Yes, after the server's Retry-After if it sent one
502, 504Yes, under the same rules as "no response"
408Yes — the server says it never got the whole request
Other 4xxNo — the same bytes get the same answer
500A 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?

Flag

Your client sends a JSON body to POST /reports and wants the report back as CSV. Which headers say that?

Flag

Your POST gets back status 471, a code you've never seen. Per RFC 9110, how should a client treat it?

Flag

DELETE /orders/7 returns 204. A retry of the same request returns 404. Is DELETE still idempotent?

Flag

The connection drops before any response arrives. Which request may an HTTP client library resend automatically, with no other information?

Flag

GET /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?

Flag

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?

Flag

Your server completed Idempotency-Key: k1 for {"amount": 40}. Now k1 arrives again with {"amount": 50}. What should it do?

Flag

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.

Flag
HTTP/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.

Flag
await fetch("/api/orders", {
  method: "POST",
  headers: { "": "application/json" },
  body: JSON.stringify(order),
});

fetch resolved with a 500. Turn that into an error.

Flag
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.

Flag
const 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());
Flag
// 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();
Flag

Challenge

Make the retry helper safe for POST

Flag

sendWithRetry 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, PUT and DELETE are idempotent: resend them as they are.
  • POST and PATCH are not. Resend them only with an idempotency-key header — 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 (send rejected), or the status means "later": 408, 429, 502, 503, 504. Everything else is the final answer — including 500, which this API uses for bugs, not for overload.
  • Before a retry, wait retry-after seconds if the response has a numeric one; otherwise baseDelayMs, 2 × baseDelayMs, 4 × baseDelayMs, … (Retry-After may 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 POST carries the same idempotency-key, made once with newKey()
  • A PATCH gets a key too; GET, HEAD, OPTIONS, PUT and DELETE are retried without one
  • A key the caller already set is kept, not replaced
  • Only a dropped connection, 408, 429, 502, 503 and 504 are retried; any other status is returned after one attempt
  • A numeric retry-after (seconds) sets the wait; otherwise the wait is baseDelayMs, then double each time
  • After maxAttempts, the last response is returned or the last network error is rethrown

Loading the in-browser editor…

Further reading