HTTPmethodssafetyidempotencyretriesproxies

HTTP Methods Are Promises

Safe means "calling this changes nothing"; idempotent means "calling this twice equals calling it once". Retrying clients, proxies, caches and crawlers all act on those promises without asking — which is why breaking them breaks things you have never heard of.

▶ Run the labFollow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
What is each HTTP method allowed to promise about side effects and repetition — and which infrastructure is already acting on that promise?
Consumers
Not just your clients: retry middleware in SDKs you did not write, corporate proxies, CDNs, browser prefetchers, load balancers replaying idempotent requests after connection loss, and monitoring probes — all of which assume method semantics without reading your docs.
The promise
Every operation is mapped to a method whose safety and idempotency promises are actually true for it — so generic HTTP machinery (retries, caches, prefetchers) helps instead of corrupting.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

Two properties carry all the weight

Strip HTTP methods of folklore and two properties remain. Safe: the request causes no state change the client can be held responsible for — GET and HEAD promise this. Idempotent: N identical requests have the effect of one — GET, PUT and DELETE promise this; POST pointedly does not. Everything practical follows: what may be retried without asking, what may be cached, what a prefetcher may touch, what a proxy may replay after a dropped connection.

The properties are about *effects*, not responses. DELETE is idempotent even though the second call may return 404 where the first returned 204 — the resource is equally gone either way (see DELETE: What Does Gone Mean?). A GET whose handler increments a view counter is arguably still safe — the client did not ask for or take responsibility for that side effect. The line is: would repeating or prefetching this request change what the client is accountable for?

These are promises *you* make by choosing the method — HTTP does not enforce them. The router will happily bind state mutation to GET. What enforces the promises is the ecosystem: everything between your handler and the user was built assuming they hold, and it acts accordingly, silently, at scale.

The method contract, and who acts on each promise
MethodSafeIdempotentActed on by
GETyesyesCaches store it, prefetchers fetch it, retries fire freely, crawlers follow it
HEADyesyesLink checkers, caches revalidating
PUTnoyesRetry middleware resends on timeout without a key
DELETEnoyesRetry middleware resends; second 404 is expected (see DELETE: What Does Gone Mean?)
POSTnonoNothing retries it blindly — this is the method's value, not a defect (see POST: More Than Create)
PATCHnonot guaranteedRetried only when you add idempotency (see PUT vs PATCH)

The invisible audience

The reason method semantics outrank naming taste: the audience for them is mostly software you will never meet. A mobile SDK's retry interceptor resends idempotent methods on timeout by default. A corporate proxy that lost the upstream connection mid-request replays it — if the method claims idempotency. A CDN caches GET responses because GET said "safe" (see Caching as a Contract Clause). A browser preloads a link the user might click. None of these consulted your documentation; they consulted the method.

This audience is also why retries are the load-bearing case. Networks lose responses — a timeout does not tell the client whether the request executed (see Retries and Timeouts as Contract Guidance). The entire recovery strategy of every generic client is method-shaped: idempotent → resend; POST → do not resend without an application-level guarantee like an Idempotency Keys: The Mechanism key. Declare the wrong method and you have set every generic client's recovery strategy wrong, everywhere, at once.

  • Retry middleware resends GET/PUT/DELETE on timeout by default — your semantics decide whether that is recovery or corruption.
  • Proxies and load balancers may replay idempotent requests after connection failure, per spec.
  • Caches (browser, CDN, corporate) store and serve GET responses; a mutating GET can be *served from cache* — mutation silently skipped.
  • Prefetchers and crawlers issue GETs to anything visible, on no one's command.
  • Monitoring probes GET health and sample endpoints continuously; unsafe GETs turn observability into load-bearing writes.

Lying to the infrastructure

The everything-POST API (each operation tunneled through POST /doThing) does not break — it *forfeits*. Nothing can be cached, nothing can be safely retried, every intermediary must assume the worst about every call. Failures that idempotent methods absorb automatically become manual incident response; the client teams each build their own retry-with-dedup layer, differently and wrong. The opposite lie — mutating GETs — fails louder and stranger: prefetchers fire the mutation, caches absorb it, crawlers trigger it row by row (the canonical incident lives in GET: The Promise of Safety).

The design discipline is one question per operation, asked at review: *what happens when this exact request arrives twice?* If the honest answer is "the same end state" — declare an idempotent method and get the ecosystem's recovery for free. If it is "a second effect" — that is POST, and the operation needs an application-level idempotency answer before it carries anything valuable (see Idempotency). The method line in your spec is not a style choice; it is the machine-readable summary of that answer.

Semantics forfeited: every request is a mystery box
1POST /api/doAction { "action": "getUser", "id": 42 }
2POST /api/doAction { "action": "deleteUser", "id": 42 }
3POST /api/doAction { "action": "renewLease", "id": 42 }
4
5# reads: uncacheable, unprefetchable
6# deletes: unretryable without custom dedup
7# every intermediary assumes maximum danger
8# a timeout on ANY call → manual recovery
Semantics declared: infrastructure recovery for free
1GET /users/42 # cached, prefetched, retried freely
2DELETE /users/42 # retried on timeout; 2nd call 404 = done
3PUT /leases/42 # full-state renewal: resend = same state
4POST /users # not idempotent — so it carries
5Idempotency-Key: 8a1f# an application-level answer instead

The right column is not more RESTful — it is more *legible to machines*. Every retry policy, cache and proxy between handler and user now makes correct decisions without coordination. The left column forces every one of them to either assume the worst or guess.

Key points

  • Safe and idempotent are the only two properties that matter; every operational behavior (retry, cache, prefetch) derives from them.
  • Idempotency is about end-state effects, not identical responses — DELETE returning 404 the second time is still idempotent.
  • The audience for method semantics is mostly infrastructure you will never meet, acting without reading your docs.
  • Declaring the wrong method sets every generic client's failure recovery wrong at once.
  • Ask per operation: "what happens when this request arrives twice?" — the method is the machine-readable form of the answer.
  • Everything-POST forfeits the ecosystem; mutating GETs weaponize it.

Method Semantics Under Retry

Change the contract and observe which guarantee moves.

Method Semantics Under Retry
Pick a method, lose the response, retry — what may the client assume?
Safe (no intended mutation)
No — intermediaries must not trigger it on their own.
Idempotent by contract
No — each request may add another effect.
Cacheable by default
No — responses are per-request.

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → API: tunnels all operations through POST because "the framework routes on the body anyway".
  2. 2
    Mobile SDK → API: disables retries entirely — nothing is marked idempotent — so every network blip becomes a user-visible error.
  3. 3
    Client team → SDK: builds custom retry-with-dedup for the worst endpoints; three clients, three dedup dialects.
  4. 4
    Ops → API: nothing is cacheable, so read traffic hits origin at full volume; a traffic spike becomes an outage a CDN would have absorbed.
  5. 5
    Team → post-incident: retrofits GET/PUT/DELETE semantics; every client's hand-rolled recovery layer must now be unwound.
What breaks
  • Transient network failures surface as user-visible errors or duplicated effects, depending on which wrong guess each client made.
  • Caching and prefetching are forfeited (everything-POST) or corrupted (unsafe GET), losing the web's free performance layer.
  • Every intermediary between client and server must treat every request as dangerous, disabling the recovery behavior HTTP was designed to give.

Design, observe, evolve

A contract decision is incomplete until you know how you would notice it failing and how it changes later.

Design the contract
  • • Map each operation to the method whose safety/idempotency promises are actually true for it; audit the map in API review.
  • • Give non-idempotent operations (POST) an application-level idempotency mechanism before they carry money or irreversible effects (see [[idempotency-keys]]).
  • • State retry guidance per operation in the docs — method semantics plus [[retryability]] signals make client recovery deterministic.
  • • Never overload one endpoint with mixed semantics (a GET that sometimes writes, a POST that sometimes just reads) — split them.
Observe in production
  • • Duplicate side effects correlated with client timeouts reveal idempotency promises that the implementation does not keep.
  • • Writes appearing in access logs from crawler/prefetcher user agents mean a safe method is mutating.
  • • A cache-hit ratio near zero across the API is the everything-POST tax, measurable in origin load.
Evolve without breaking
  • • Method semantics are the most frozen clause in HTTP — changing an operation's method is a breaking change for every client and intermediary, so migrate by adding the new operation alongside (see [[api-migration]]).
  • • Semantics can be *strengthened* compatibly: making a POST handler internally idempotent breaks no one and quietly de-risks every existing retry.
  • • Weakening is the forbidden direction: a PUT that stops being idempotent invalidates recovery behavior consumers already deployed.
What it costs
  • • Honoring PUT/DELETE idempotency constrains implementations — handlers must tolerate replays and out-of-order arrivals, which costs design effort.
  • • Splitting mixed-semantics endpoints multiplies routes and can turn one call into two for some clients.
  • • Strict method discipline occasionally fights tooling (HTML forms, ancient proxies, gRPC-web bridges) that only speak GET/POST — the workarounds need documenting.

Misconceptions

Claim
“Idempotent means the client gets the same response every time.”
Reality
It means the same *end state* results. DELETE twice → 204 then 404, both leave the resource gone; that is idempotent. Chasing response-identity leads teams to wrongly conclude DELETE cannot be idempotent and to disable safe retries.
Claim
“Method choice is REST style; our clients only care about the payload.”
Reality
Your *named* clients might. The retry interceptor in their HTTP library, the CDN, the corporate proxy and the crawler care only about the method — and they act on it. Method choice is operational behavior, not style.
Claim
“POST is the safe default when unsure.”
Reality
POST is the *conservative* default — it forfeits retries, caching and prefetching. For reads and full-state writes that forfeit buys nothing and costs the ecosystem's entire recovery layer. Unsure means the twice-arrival question has not been asked yet; ask it.

Apply it