HTTP: Requests, Responses, Headers and Status Codes
HTTP turns a byte stream into a request — method, path, headers, optional body — and a response — status code, headers, optional body; the method tells intermediaries whether a request is safe to retry and the status code tells the client what to do next.
The problem
Progressive depth
The same mechanism at different altitudes — start where you are.
A request says what to do to which resource; a response says how it went with a three-digit code and returns the data. Headers on both sides carry metadata: format, length, caching, authentication.
One request, one response
An HTTP/1.1 request is a request line — method, target, version — followed by header lines, a blank line, and an optional body whose length is announced by Content-Length or delimited by chunked encoding. The response is a status line — version, code, reason phrase — followed by headers, a blank line and a body. Header names are case-insensitive; the blank line (\r\n\r\n) is the only delimiter between headers and body, which is why a stray newline in a header value is a parsing bug and a security hole.
Host is mandatory in HTTP/1.1 because one IP address serves many sites: the TCP connection reached 93.184.216.34, and only the Host header says which of the hundred sites on that address the client meant. It is the HTTP-layer analogue of SNI in TLS, and a mismatch between the two is a common proxy misconfiguration.
GET /users/42 HTTP/1.1
Host: example.com
Accept: application/json
Accept-Encoding: gzip, br
User-Agent: curl/8.5.0
Authorization: Bearer eyJhbGciOi...
HTTP/1.1 200 OK
Date: Tue, 25 Aug 2026 10:04:12 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 58
Cache-Control: private, max-age=0
ETag: "5f3a-1a2b"
{"id":42,"name":"Ada","email":"ada@example.com","plan":"pro"}Methods: safety and idempotency
The method is a promise about side effects, and everything between client and server relies on it. A safe method (GET, HEAD, OPTIONS) must not change state, so caches may serve it and prefetchers may issue it speculatively. An idempotent method (GET, HEAD, PUT, DELETE, OPTIONS) produces the same server state whether it is executed once or five times, so a client, proxy or load balancer that lost the response may retry it. POST is neither: retrying "create an order" creates two orders. PATCH is not guaranteed idempotent (a "increment by one" patch is not; a "set to seven" patch is).
This is not academic. Browsers retry idempotent requests on a dropped Keep-Alive and Connection Reuse connection; curl --retry, gRPC, AWS SDKs and service meshes retry based on the method; a POST that times out at a load balancer after the server processed it is the origin of most duplicate-charge bugs. The fix for non-idempotent operations is an idempotency key (Idempotency-Key: 7a8f…) the server deduplicates on, which is the application re-creating the guarantee HTTP could not give it.
GET— read; safe, idempotent, cacheable. Body is allowed by the grammar but ignored by most servers and stripped by many proxies.POST— create or "do something"; not idempotent; retry only with an idempotency key.PUT— replace the whole resource at this URL; idempotent.PATCH— partial update; idempotent only if the patch document is (set x=7yes,increment xno).DELETE— idempotent: the second call finds nothing and may return 404 or 204, but state is the same.HEAD—GETwithout the body;OPTIONS— capabilities, used by CORS preflight.
Status codes engineers actually meet
The first digit is the class: 1xx informational, 2xx success, 3xx redirection, 4xx the client did something the server refuses, 5xx the server failed. A client that does not know a specific code must treat it as the x00 of its class. The specific codes below account for nearly all real traffic and nearly all real arguments about API design.
The three that come from proxies deserve their own lesson: 502 means the proxy got an invalid or no response from upstream (upstream down, wrong port, TLS mismatch), 503 means the upstream (or the proxy) declared itself unavailable (no healthy backends, overload, maintenance), 504 means the upstream did not answer within the proxy’s timeout. They tell you *which hop gave up and why* — see Load Balancers: L4 vs L7 and HTTP Debugging: 502, 503 and 504 Are Different Failures.
| Code | Meaning | When you see it / what to do |
|---|---|---|
| 200 OK | Success with body | The normal case. Also what many APIs wrongly return for errors ("200 with {error: …}") — do not. |
| 201 Created | Resource created | Response to POST/PUT that made something; Location header points at it. |
| 204 No Content | Success, no body | DELETE, or PUT that has nothing to say. Clients must not try to parse a body. |
| 301 / 308 | Moved permanently | Cached by browsers and search engines. 301 may turn POST into GET; 308 preserves the method. |
| 302 / 307 | Moved temporarily | Not cached. 302 historically rewrites POST to GET; 307 preserves the method. Use 307/308 for APIs. |
| 304 Not Modified | Your cached copy is still valid | Answer to a conditional GET (If-None-Match / If-Modified-Since); no body, saves the transfer. |
| 400 Bad Request | Malformed or invalid request | Validation failed, bad JSON, header too large. Do not retry unchanged. |
| 401 Unauthorized | Not authenticated | Misnamed: it means "who are you?". Send credentials (WWW-Authenticate says how). |
| 403 Forbidden | Authenticated but not allowed | Credentials are fine; permission is not. Retrying with the same identity will not help. |
| 404 Not Found | No such resource | Also used to hide existence (instead of 403). Idempotent DELETE may return it on the second call. |
| 409 Conflict | State conflict | Optimistic-concurrency failure (If-Match did not match), unique-key violation, "already exists". |
| 429 Too Many Requests | Rate limited | Honour Retry-After; back off with jitter, do not hammer. |
| 500 Internal Server Error | Unhandled server failure | A bug or an exception; look at server logs, not the network. |
| 502 Bad Gateway | Proxy got an invalid/no response from upstream | Upstream crashed, wrong port, upstream reset the connection, protocol mismatch. |
| 503 Service Unavailable | Upstream/proxy unavailable | No healthy backends, overload shedding, maintenance; Retry-After may be set. |
| 504 Gateway Timeout | Upstream too slow for the proxy | Proxy timeout shorter than the upstream’s work; the upstream may still finish the work. |
Headers: negotiation, caching, the body
Headers carry everything the message line does not. Content negotiation lets one URL serve several representations: Accept: application/json and Accept-Encoding: gzip, br say what the client can take; the server answers with Content-Type and Content-Encoding, and Vary: Accept-Encoding tells caches that the response depends on that request header. Compression is negotiated here, which is why a missing Accept-Encoding from a naive client makes every response several times larger.
Caching headers decide whether the next request happens at all. Cache-Control: max-age=300 lets a browser or CDN answer from cache for five minutes without asking; no-store forbids storing; private keeps it out of shared caches. After expiry, a conditional request with If-None-Match: "5f3a-1a2b" (the ETag from before) or If-Modified-Since lets the server answer 304 with no body. The same ETag in If-Match turns a PUT into an optimistic-concurrency update that fails with 412 or 409 if someone else changed the resource first.
The body is framed by Content-Length (exact byte count) or Transfer-Encoding: chunked (length-prefixed pieces, terminated by a zero chunk) — see HTTP/1.1: Persistent Connections and Their Limits for why both exist and why having both in one message is a smuggling attack. Content-Type is the only thing that tells the receiver how to parse it; application/json, application/x-www-form-urlencoded, multipart/form-data and text/html all cross the wire as bytes.
1const res = await fetch('https://example.com/users/42', {2 method: 'GET', // safe + idempotent → the runtime may retry on a dead keep-alive socket3 headers: {4 Accept: 'application/json', // content negotiation5 Authorization: `Bearer ${token}`,6 'If-None-Match': cachedEtag ?? '', // conditional GET → 304 if unchanged7 },8})9if (res.status === 304) return cached // no body on 304; do not call res.json()10if (res.status === 429) {11 const wait = Number(res.headers.get('Retry-After') ?? 1)12 // back off; do not retry immediately13}14if (!res.ok) throw new Error(`HTTP ${res.status}`) // ok === 200..29915const user = await res.json() // trusts Content-Type: application/jsonKey points
- Request = method + target + headers + optional body; response = status + headers + optional body;
\r\n\r\nseparates headers from body. Hostis mandatory because one address serves many sites; it is the HTTP-layer counterpart of SNI.- Safe methods can be cached and prefetched; idempotent methods can be retried by anyone on the path;
POSTis neither, so use idempotency keys. - 2xx success, 3xx go elsewhere, 4xx you did something wrong, 5xx the server or a proxy failed; 502/503/504 identify which hop gave up.
- 307/308 preserve the method on redirect; 301/302 historically do not, and 301/308 are cached.
- Content negotiation (
Accept*/Content-*/Vary) picks a representation;Cache-Control,ETagand conditional requests decide whether the request happens at all.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why is HTTP text at all?
It was designed in 1991 to be written by hand over telnet and debugged by reading it, and that property made it easy to implement and to proxy. The cost — parsing ambiguity and repeated header bytes — is what HTTP/2 fixed with binary framing (see HTTP/2: Streams on One Connection).
▸Why do proxies care about the method?
Because a proxy that lost the response has to decide whether to retry, and it cannot know what the application does. The method is the one contract it can rely on: idempotent requests are safe to resend, POST is not.
▸Why distinguish 401 from 403?
They prompt different client behaviour. 401 says "authenticate and try again" and carries a WWW-Authenticate challenge; 403 says "I know who you are and the answer is no", so retrying with the same credentials is pointless.
▸Why do status codes have classes?
So a client written before a code existed still behaves sensibly: an unknown 4xx is treated as 400, an unknown 5xx as 500. Extensibility without a registry lookup.
Anatomy of an HTTP message
GET /users/42 HTTP/1.1Host: api.engineer-atlas.devAccept: application/jsonAuthorization: Bearer eyJhbGciOiJIUzI1NiJ9…
HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8Content-Length: 61Cache-Control: private, max-age=60{"id":42,"name":"Ada","role":"admin","createdAt":"2026-08-25"}
const res = await fetch('https://api.engineer-atlas.dev/users/42', {
method: 'GET',
headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) // 200 → ok
const user = await res.json()How it fails
What the failure looks like from inside real software.
- Duplicate orders: a
POSTretried by a client library or load balancer after a timeout; the server processed both. Add an idempotency key. - Errors returned as
200 OKwith an error body: caches store them, monitoring sees a healthy service, and retry logic never triggers. 302after aPOSTturns the follow-up into aGETand loses the body; use307/308.- Missing
Vary: Accept-Encodingat a cache serves gzip bytes to a client that did not ask for them; the page renders as garbage. - Response has both
Content-LengthandTransfer-Encoding: chunked; front and back servers disagree about where the message ends — request smuggling. - Client calls
res.json()on a204or304and throws on the empty body.