HTTP semantics and caching
Article.
HTTP semantics are the parts that stay the same across HTTP/1.1, HTTP/2 and HTTP/3: methods, status codes, header fields, content negotiation, conditional requests and caching. RFC 9110 defines them, RFC 9111 defines caching and RFC 9112 the HTTP/1.1 wire format; all three appeared in June 2022 and replaced RFC 7230-7235. Version differences are in HTTP versions: 1.1, 2 and 3, message basics in HTTP.
Behind “which methods are idempotent?” sits a better question: what each method licenses software you do not control to do with a request. Crawlers, retry loops, proxies and caches act on these properties without asking you.
Safe, idempotent, cacheable
RFC 9110 section 9.2 defines three properties, each narrower than the folklore:
- Safe: the client does not request, and does not expect, a state change on the server. Logs, metrics and an audit row are allowed; what counts is that the client did not ask for the side effect and cannot be held accountable for it.
- Idempotent: the intended effect of N identical requests equals the
effect of one.
DELETEanswering 204 and then 404 is idempotent: the state is the same, and the response is allowed to differ. - Cacheable: the method’s definition allows a response to be stored and
reused. Necessary, not sufficient: RFC 9111 still checks the status code,
Cache-ControlandAuthorizationbefore anything is stored.
| Property | Relied on by | What it licenses |
|---|---|---|
| Safe | crawlers, prefetchers, link scanners | sending it with no user present |
| Idempotent | clients, proxies | resending after a connection failure |
| Cacheable | browsers, CDNs, reverse proxies | answering later requests from a copy |
The retry licence is narrow. A client may repeat an idempotent request “if a communication failure occurs before the client is able to read the server’s response”; it SHOULD NOT repeat a non-idempotent one unless it knows the repeat is harmless, which in practice means an idempotency key (Idempotency keys).
Every method
| Method | Properties | Use |
|---|---|---|
GET |
safe, idempotent, cacheable | read a representation |
HEAD |
safe, idempotent, cacheable | GET without the content |
OPTIONS |
safe, idempotent | capabilities; CORS preflight |
TRACE |
safe, idempotent | loop-back echo; keep it disabled |
PUT |
idempotent | create or replace at a known URI |
DELETE |
idempotent | remove the URI’s current mapping |
POST |
cacheable only on conditions | process the enclosed content |
PATCH |
cacheable only on conditions | apply a partial modification (RFC 5789) |
CONNECT |
none | open a tunnel through a proxy |
QUERY |
safe, idempotent, cacheable | a read whose input is the body |
POSTandPATCHare cacheable on paper - only with explicit freshness plus aContent-Locationequal to the target URI, and RFC 9110 admits that “the overwhelming majority of cache implementations only support GET and HEAD.”OPTIONSresponses are never cacheable. A CORS preflight is cached byAccess-Control-Max-Ageinstead: 5 s by default, capped at 7200 s in Chromium and 86400 s in Firefox (CORS — The Deep Dive).PATCHis not idempotent by definition, though a given patch can be: “set status to paid” is, “append to the list” is not (PUT vs PATCH: which writes are safe to retry).- A body on
GET,HEADorDELETEhas no defined semantics, and RFC 9110 warns that servers may reject one as a possible smuggling attempt. TRACEechoes the request back, which once let scripts readHttpOnlycookies (cross-site tracing); browsers may not send it, and servers should disable it.CONNECT host:portis how HTTPS crosses a forward proxy.- An unrecognised method gets 501; a recognised one the resource refuses
gets 405, which MUST list the allowed methods in
Allow.
QUERY: a safe read with a body
QUERY became RFC 10008 in June 2026. It fills the gap between GET, which
cannot carry a body, and POST, which can but promises neither safety nor
idempotency - the old workaround for search endpoints with large filters:
QUERY /orders HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"status": "open", "customer_id": {"in": [17, 42, 99]}, "limit": 50}It is safe and idempotent, so clients may retry it and caches may store it, with the request body in the cache key. As of 2026-10 it is new: check that every hop - CDN, proxy, client library - passes the method through before relying on it.
Who retries for you
Retries happen in code you did not write, and the safe/idempotent properties are what they key on:
| Client | Default | Retries what |
|---|---|---|
| urllib3 2.x | 3 retries | connect errors always; read errors if idempotent |
| requests | none | max_retries=0 unless you mount a Retry |
| httpx | none | retries=n on the transport covers connect failures only |
| aiohttp | once | a dropped reused connection, idempotent methods only |
| nginx | next upstream | errors and timeouts; never a POST/PATCH already sent |
Read from the installed libraries on 2026-10-15:
from urllib3.util import Retry
sorted(Retry.DEFAULT_ALLOWED_METHODS)
requests.adapters.HTTPAdapter().max_retries
httpx.HTTPTransport()._pool._retriesurllib3 2.8.0 ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT', 'TRACE']
requests 2.34.2 Retry(total=0, connect=None, read=False, redirect=None, status=None)
httpx 0.28.1 0So POST and PATCH are never retried for you, and with requests and httpx
nothing is unless you configure it. A retry policy for outbound calls is in
Writing a client for an external API.
Keep-alive is per hop, and when a load balancer reuses an idle connection the
app has just closed, the request meets a reset and the user gets a 502. The
app’s idle timeout must outlive the load balancer’s (uvicorn
--timeout-keep-alive 75 behind an ALB’s 60 s); the full story is in
What happens when you enter a URL.
Content negotiation
The request states preferences, the server picks a representation and labels
it, and Vary records which request fields it consulted:
GET /reports/42 HTTP/1.1
Accept: application/json, text/csv;q=0.5
Accept-Encoding: zstd, br, gzip
HTTP/1.1 200 OK
Content-Type: application/json
Content-Encoding: br
Vary: Accept, Accept-EncodingWith nothing acceptable the server may answer 406 or send its default; a
request body in a format it cannot read gets 415. Content-Encoding
belongs to the representation and travels end to end; Transfer-Encoding is
per-hop framing.
Conditional requests
A conditional request carries a validator from an earlier response and asks the server to act only if the resource is, or is not, still in that state. Cache revalidation and lost-update prevention are the same mechanism.
Validators
ETag: "v7"is strong: it changes on any byte-level change to the representation, content coding included.ETag: W/"v7"is weak: it promises only semantic equivalence.Last-Modifiedis an HTTP date with one-second resolution, so two writes in one second share a value; treat it as weak. A version counter or a content hash makes a good strong ETag.
| Tags compared | Strong comparison | Weak comparison |
|---|---|---|
W/"1" and W/"1" |
no match | match |
W/"1" and "1" |
no match | match |
"1" and "1" |
match | match |
If-Match must use strong comparison; If-None-Match uses weak.
Preconditions
| Header | Used for | When false |
|---|---|---|
If-None-Match |
cache revalidation | 304 on GET/HEAD, else 412 |
If-Modified-Since |
revalidation by date | 304; ignored next to If-None-Match |
If-Match |
lost-update prevention | 412 |
If-Unmodified-Since |
the same, by date | 412 |
If-None-Match: * |
create only if absent | 412 when it exists |
If-Range |
resuming a download | the full 200 instead of 206 |
They are evaluated in a fixed order (RFC 9110 section 13.2.2) and only after
the normal checks, so a request that would get 404 or a redirect gets that,
never 412. 428 Precondition Required (RFC 6585) lets a server make If-Match
mandatory.
The lost-update fix is “write only if the version I read is still current”, and the comparison and the write must be one statement, or two writers both pass the check:
UPDATE docs SET body = $1, version = version + 1
WHERE id = $2 AND version = $3 -- the version from the client's If-Match
RETURNING version; -- no row back: answer 412Gotcha: since 1.7.3, nginx turns a strong
ETagweak whenever gzip or another filter rewrites the response. A client that echoes it inIf-Matchthen gets 412 on every update. Compress in the app with a strong tag per encoding, or know that only the opaque part is being compared.
The full PUT and PATCH contracts, with If-Match in a FastAPI handler, are in
PUT vs PATCH: which writes are safe to retry.
Caching
Private and shared caches
A private cache serves one user: the browser’s. A shared cache serves
many: a CDN, nginx proxy_cache, Varnish, a corporate proxy. One response can
pass through several:
browser ──▶ CDN edge ──▶ CDN shield ──▶ nginx ──▶ app
private shared shared sharedThe cache key is the method plus the target URI, extended by the request
fields the response names in Vary. Browsers also add the top-level site
(“double keying”), so one site cannot learn from load times what you visited
elsewhere.
What may be stored
RFC 9111 section 3 lets a cache store a response only when all of these hold:
- The method is understood (in practice
GETorHEAD) and the status is final. - There is no
no-store. - For a shared cache: no
private, and if the request carriedAuthorization, the response sayspublic,s-maxageormust-revalidate. - There is explicit freshness (
max-age,s-maxage,Expires),publicorprivate, or a heuristically cacheable status such as 200, 301, 404 or 410.
Rule 4 is the one people miss: a 200 with no caching headers at all is storable and gets a heuristic lifetime. Rule 3 is why a CDN does not hand one user’s authenticated response to another by default.
Gotcha:
Set-Cookiedoes not make a response uncacheable. nginx’sproxy_cachehappens to skip such responses; other caches need not. Mark personalised responsesprivateorno-store.
Freshness and revalidation
A stored response is fresh while its age is below its freshness lifetime, and a fresh response is served without asking the origin. The lifetime is the first of these that applies (RFC 9111 section 4.2.1):
s-maxage, for shared caches only.max-age- which overridesExpires.ExpiresminusDate.- A heuristic, typically 10% of the time since
Last-Modified.
A stale copy is revalidated with If-None-Match or If-Modified-Since, and a
304 refreshes it without resending the body. A cache may serve a stale copy
without asking only when disconnected or explicitly allowed, and never against
no-cache or must-revalidate.
The directives
| Directive | Meaning |
|---|---|
max-age=N |
fresh for N seconds; overrides Expires |
s-maxage=N |
the same for shared caches; implies proxy-revalidate |
no-cache |
may store; must validate before every reuse |
no-store |
must not store any part of it |
private |
browser only; shared caches must not store it |
public |
storable even where it otherwise would not be |
must-revalidate |
once stale, validate or fail with 504 |
immutable |
no revalidation while fresh, even on reload (RFC 8246) |
stale-while-revalidate=N |
serve stale for N s while refreshing (RFC 5861) |
stale-if-error=N |
serve stale for N s if the origin fails (RFC 5861) |
The four that get confused:
| Header | Stored? | Reused without the origin? |
|---|---|---|
no-store |
no | never |
no-cache |
yes | never; every reuse is validated |
max-age=0 |
yes | if disconnected, or the client sends max-stale |
max-age=0, must-revalidate |
yes | never; 504 while the origin is down |
Vary
Vary names the request fields that shaped a response, and caches add them to
the key:
Vary: Accept-Encodingwherever responses may be compressed, or a cache hands gzip to a client that cannot read it.Vary: Originwherever CORS headers echo the request’sOrigin.Vary: CookieorVary: User-Agentsplits the key per user or per browser build, and the hit rate falls toward zero.Vary: Authorizationis unnecessary: shared caches already refuse to reuse authenticated responses unless told otherwise (rule 3 above).
Invalidation and what the cache did
A successful unsafe request (POST, PUT, DELETE) that passes through a
cache makes it invalidate the target URI (RFC 9111 section 4.4). Only caches on
that request’s path hear about it; every other browser and CDN node keeps its
copy until it goes stale - hence versioned asset URLs, and purge APIs or short
lifetimes for everything else (CDN (Content Delivery Network)).
To see what happened, read Age (seconds since the origin generated or last
validated the response) and Cache-Status (RFC 9211), one entry per cache:
Age: 97
Cache-Status: "Edge"; fwd=stale; fwd-status=200; storedCDN-Cache-Control (RFC 9213) is a Cache-Control aimed only at CDNs, which
other caches ignore - the standard way to let the edge cache for an hour while
browsers revalidate every time.
Recipes
| Response | Headers | Why |
|---|---|---|
| hashed asset | public, max-age=31536000, immutable |
the URL changes with the content |
| HTML, API reads | no-cache and an ETag |
always current; 304s are cheap |
| per-user data | private, no-cache |
never in a shared cache |
| tokens, PII | no-store |
never written down |
| hot public read | max-age=60, stale-while-revalidate=300 |
one origin hit per key per minute |
| CDN-only TTL | no-cache plus CDN-Cache-Control: max-age=600 |
browsers revalidate; the edge takes the load |
Browser-side layers are in
HTTP Caching Headers — Cache-Control, ETag, Vary,
nginx’s proxy_cache in Nginx Caching, and
stampede control in Cache Stampede and Mitigation Patterns.
HTTP/1.1 on the wire
Where a message ends
HTTP/1.1 sends messages back to back on a persistent connection, so each one must say where it ends. RFC 9112 section 6.3 decides, in order:
- A response to
HEAD, and any 1xx, 204 or 304 response, has no body. Transfer-EncodingandContent-Lengthtogether:Transfer-Encodingwins, and the message “ought to be handled as an error”.Transfer-Encoding: chunked: read chunks until the zero-size one.- A valid
Content-Length: exactly that many bytes. - Otherwise a request has no body, and a response runs until the server closes the connection.
HTTP/2 and HTTP/3 frame messages themselves, with DATA frames and an end-of-stream flag, so this whole class of problem lives where a proxy translates between versions or speaks HTTP/1.1 to the back end.
Request smuggling
Smuggling is a disagreement about where a request ends between a front end and
a back end that share a keep-alive connection. In the classic CL.TE case the
front end frames by Content-Length and the back end by Transfer-Encoding:
| Bytes | Front end (length) | Back end (chunked) |
|---|---|---|
POST /cart + headers |
request 1 | request 1 |
0\r\n\r\n |
body | end of body |
GET /admin ... |
more body | request 2 |
The front end applied its auth and routing rules to request 1 only; the back end answers a second request nobody checked, on the same connection. TE.CL reverses the roles, and HTTP/2 front ends that downgrade to HTTP/1.1 create H2.CL and H2.TE variants. Defences, strongest first:
- Speak HTTP/2 to the back end, so lengths come from frames - the conclusion of PortSwigger’s “HTTP/1.1 must die” research (August 2025).
- Reject ambiguous framing at the edge: both length headers, a body on a method that needs none. nginx has rejected both headers together since 1.21.1; AWS ALB’s default desync mode blocks the severe cases.
- Run a strict parser at the origin (uvicorn with
httptoolsrejects the combination with 400; the pure-Pythonh11parser is more lenient). - Detect it by comparing per-connection request counts in front and back logs.
Hop-by-hop headers
Some fields describe one connection, not the message: Connection,
Keep-Alive, TE, Transfer-Encoding, Upgrade, and any field named in
Connection. A proxy strips them before forwarding. So keep-alive is
negotiated per hop, Upgrade does not survive a proxy unless it is re-added
for WebSocket routes (Nginx WebSocket Proxying),
and HTTP/2 forbids these fields outright.
Status codes worth knowing here
Classes and the confusable pairs live in Status codes. What RFC 9110 adds that interviews probe:
| Code | Rule |
|---|---|
| 301, 302 | a client MAY turn POST into GET, for historical reasons |
| 303 | the answer to a POST; the client follows with GET |
| 307, 308 | redirects that must keep the method |
| 412 | a false precondition; not 409, a resolvable conflict |
| 413 | renamed Content Too Large |
| 422 | Unprocessable Content; core HTTP since RFC 9110 |
Error bodies follow RFC 9457 (Problem Details), which obsoleted RFC 7807.
Where it goes wrong
- State change on
GET. Mail-security scanners and chat link previews fetch the URLs in messages, so a one-time login or unsubscribe link that acts onGETis used up before the user clicks. Show a page whose buttonPOSTs. - Web cache deception: a CDN rule caches
/account/settings/x.cssby its extension while the origin ignores the suffix and serves the private page, now public. Cache byCache-Control, never by extension. - Web cache poisoning: a header the cache does not key on, such as
X-Forwarded-Host, is reflected into a cached page, and every later visitor gets the attacker’s version. Key on what you reflect, or stop reflecting it. - A 301 without
Cache-Controlis kept by browsers indefinitely, so a mistaken permanent redirect outlives its fix. Use 302 or 307 until sure.
The trade not taken: no-store everywhere
The “safe” policy after a caching incident is Cache-Control: no-store on
every response. It does prevent the private-page leaks above, and it throws
away everything caching was for: no revalidation, so a client that already
has the current version downloads it again instead of getting a 304; no CDN
offload for assets and public pages; and a slower site for every visitor to
avoid a bug in a few routes.
The policy that holds up is per route, stated explicitly:
| Response | Header | Why |
|---|---|---|
| hashed static assets | public, max-age=31536000, immutable |
the URL changes when the content does |
| public pages, API reads | public, max-age=60 or s-maxage |
short freshness, shared caches help |
| per-user data | private, no-cache plus an ETag |
revalidate cheaply, never shared |
| secrets, one-time tokens | no-store |
must not exist anywhere but the client |
no-store belongs in the last row only. A framework default of private, no-cache for anything behind a session cookie, with routes opting into
public caching, is the safe direction to fail in.
Related
Interview angle 4
- “Safe, idempotent, cacheable: classify
OPTIONSandPOST.” -OPTIONSis safe and idempotent, but its responses are never cacheable; a CORS preflight is cached throughAccess-Control-Max-Ageinstead.POSTis neither safe nor idempotent, and cacheable only with explicit freshness plus a matchingContent-Location.- Follow-up: “So may a
GETwrite an audit row?” - yes. Safe means the client did not request a state change and cannot be held responsible for one; logs and audit rows are fine. Deleting, charging or burning a one-time token is not, because crawlers and link scanners send thatGETwith no user behind it.
- Follow-up: “So may a
- “Which requests will your HTTP client retry by itself?” - idempotent ones
at most: urllib3 retries read failures only for
DELETE,GET,HEAD,OPTIONS,PUTandTRACE; requests and httpx retry nothing by default; nginx never re-sends aPOSTorPATCHit already sent.- Follow-up: “Then how do you make a
POSTsafe to retry?” - an idempotency key: the server stores the first result under the key and replays it for a duplicate, so a timeout never creates two orders.
- Follow-up: “Then how do you make a
- “How does a server know where a request body ends?” - chunked transfer
coding means read chunks to the zero-size one; otherwise
Content-Lengthbytes; a request with neither has no body. HTTP/2 and HTTP/3 frame messages themselves.- Follow-up: “And if the front proxy and the back end disagree?” - request smuggling: bytes the front end treats as body become a second request at the back end, one no front-end rule checked. Reject ambiguous framing at the edge and speak HTTP/2 upstream.
- “What do
no-cache,no-storeandmax-age=0each do?” -no-storeforbids storing;no-cachestores but validates before every reuse, which is cheap with anETagand a 304;max-age=0is stale at once, yet a disconnected cache may still serve it unlessmust-revalidateis added.- Follow-up: “How does a CDN avoid serving user A’s page to user B?” -
shared caches refuse to reuse responses to
Authorizationrequests unless toldpublicors-maxage, and never storeprivateones.Set-Cookiedoes not stop caching, so cookie-session pages needprivateorno-store, and a CDN must never cache by file extension.
- Follow-up: “How does a CDN avoid serving user A’s page to user B?” -
shared caches refuse to reuse responses to