Topic 06

Methods and What They Promise

HTTP

An HTTP method is not a verb chosen for the reader's convenience. It is a promise to every client library, proxy, cache and retry loop about what happens when the request is sent again. GET promises that nothing changes. PUT and DELETE promise that doing it twice leaves the same state as doing it once. POST promises nothing at all, and that is exactly why a retried POST /orders charged a buyer twice on the night of the spring on-sale. Choosing the method is choosing who is allowed to retry.

The promises are written down in RFC 9110, and every layer between the buyer's phone and api-01 acts on them without asking. The browser's fetch, the HTTP client library, the load balancer, a CDN: each one decides whether to repeat a lost request, whether to cache the answer and whether to prefetch it by reading the method and nothing else. A service that uses the wrong method has lied to all of them at once, and none of them will say so.

Safe, Idempotent, Neither

Safe methods, GET, HEAD and OPTIONS, change nothing on the server. Any layer may repeat them, cache them or issue them speculatively, and the service has to make that true: a GET handler that writes a row has broken the promise even when the write is harmless. Idempotent methods may change state, but sending the same request twice produces the same state as sending it once. PUT and DELETE are idempotent, and every safe method is idempotent by definition. POST and PATCH are neither. The service may make a particular POST safe to repeat, and Chapter 7 does, but the method does not say so and no intermediary will assume it.

Three promises, and who acts on each one
SafeGET, HEAD, OPTIONS
Nothing changes. Caches store the answer, prefetchers issue it early, and any layer repeats it without asking.
IdempotentPUT, DELETE, and the safe ones
State may change, but twice equals once. Client libraries repeat a lost one automatically; caches do not store it.
NeitherPOST, PATCH
No promise. Nobody retries it for you, so the human does, and the service must recognize the repeat itself.

The consequence that matters lives in the retry policy of every client library. RFC 9110 permits a client to repeat an idempotent request whose response was lost, and clients do: a connection reset in the middle of a GET or a DELETE is retried by the browser, by httpx with a transport retry configured, and by the load balancer's own retry setting. A lost POST is not retried by any of them. That sounds like safety and is the opposite: the buyer sees a failed checkout, clicks again, and the click is the retry, with no library in the way to reason about it.

PUT Is Replace, PATCH Is Modify

PUT /events/8812 carries the whole representation of the event: title, start time, on-sale time, status. The server replaces what it has with what it was sent. Send it twice and the second write puts the same bytes over the same bytes; the state after two is the state after one. Idempotency for PUT is no more than that, and it holds because the client sends a state, not a change.

PATCH sends a change. A patch that says "set the title to this" happens to be idempotent. A patch that says "raise the price by 500 cents" is not, and after one retry the organizer's ticket costs 1,000 cents more instead of 500. Because the method admits both kinds, RFC 5789, which defines PATCH, calls it neither safe nor idempotent, and no client will retry it automatically whatever a particular patch body happens to do. Idempotency is a property of the operation, not of the spelling, and the method is only a claim about what the operation is allowed to be.

POST for Creation and for Everything Else

Creating an order, holding a seat, triggering a refund, sending an organizer a test email: these are the operations where doing it twice is not the same as doing it once by their nature. A second hold is a second row. A second refund is a second transfer. POST is the honest method for "do this" because it promises nothing, and that honesty is the reason the Idempotency-Key header exists: the method says the server cannot promise that twice is once, and the key is the client saying "then here is how to recognize that these two are the same one."

Stagedoor uses POST for every creation where the server assigns the identity. POST /holds returns the new hold's id; POST /orders returns an order with its public_id. A client that could name the resource in advance could use PUT instead and get idempotency from the method itself, which is the choice the comparison box below is about. Everything that is neither a read nor a replace is a POST, and every POST that creates or charges needs the key of Chapter 7.

The Hold That Was Created Twice

A buyer on a train sends POST /holds for seat 14C. The request reaches api-02, the seat row is updated, a hold is inserted with a 10-minute expiry, the transaction commits, and the response leaves the instance as the train enters a tunnel. The browser sees a dropped connection. The page's fetch call is wrapped in a retry helper, added months earlier after an unrelated flakiness report, and the helper sends the request again. api-01 receives it, finds nothing that connects it to the first, and inserts a second hold for the same buyer and the same seat. Two holds rows, one buyer, one seat, each with its own expiry, and a seat map that now shows 14C held by a buyer who thinks her first attempt failed.

Read it as a client reads it. The method promised nothing, so the retry helper was permitted to do anything, and what it did was ordinary. Neither side wrote a bad line. What was missing was a way for the second request to say "same intent as the first," which is what Chapter 7 adds by storing the key beside the response. The fix is a key, not a different verb: turning the hold into a PUT would require the client to name a hold whose id it does not have yet, and the double charge of Topic 03 of Chapter 1 is the same story one endpoint later.

HEAD and OPTIONS

HEAD is GET without the body: the same status, the same headers, nothing after them. It is the cheap way to ask for an ETag or a Content-Length before deciding whether to fetch the seat map at all. A service that renders the body only to discard it has done the work for nothing, which is why Topic 11 of this chapter derives the validator from a version column, so that a HEAD costs one row read rather than one render.

OPTIONS is the request the browser sends before the one the reader wrote. A page served from one origin calling the API on another with a JSON body triggers a CORS preflight: an OPTIONS carrying Access-Control-Request-Method, answered with what the resource allows and cached by the browser for the number of seconds in Access-Control-Max-Age. The preflight carries no credentials, must never touch state, and the CORS middleware answers it before routing runs. A service that records every preflight as an unauthenticated 401 is filling its error dashboard with browsers doing their job.

Methods the Router Should Refuse

A GET /orders/123/cancel that cancels works in a test and lies to everything else. A link prefetcher fetches it while the buyer hovers. A crawler follows it. The back button re-issues it from history. Each of them was told, by the method, that the request changes nothing. The reverse lie is quieter: a POST /events/search that only reads works too, and now no cache may store the result, no client will retry the lost answer, and at on-sale the 2,600 seat-map reads a second all land on the origin. The book uses GET for reads without exception; where the query is too long for a URL, the answer is a saved search that is created once and read by id, not a change of method.

For a method the resource does not support, the router answers 405 Method Not Allowed with an Allow header listing the ones it does. FastAPI produces both on its own when the path matches and the method does not; Stagedoor adds the Problem Details body. The header is the contract taught by the error.

The refusal that teaches the contract
DELETE /events/8812 HTTP/1.1
Host: api.stagedoor.example

HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD, PUT
Content-Type: application/problem+json

{"title": "Method not allowed", "detail": "Events are not deleted through the API; set status to cancelled."}

The exchange is a client sending a delete for an event and the service refusing it. The refusal is a 405, not a 404 and not a 400, and it carries an Allow header naming the three methods the event resource supports. A client that reads that line has learned, without opening a document, that events are read and replaced but never deleted, and the body tells it what to do instead: change the status field. The error is the shortest possible description of the resource's contract.

PUT vs POST for Creation

PUT /seats/8812-14C creates or replaces a seat at an identity the client chose: the event and the label together name the row. Twice is once, so a retry after a lost response is safe with no extra machinery. Use it whenever the client can name the resource, which is any upsert by a natural key: a seat by its label, an organizer's setting by its name.

POST /holds creates a hold at an identity the server assigns, and a retry may create a second one. Use it whenever the server must hand out the id, which is every order, hold and payment in Stagedoor, and then give it an idempotency key so the retry is recognized. The key does for a POST what the client-chosen name does for a PUT.

Common Mistakes
  • Using POST for a read because the query is long — the response is now uncacheable by every proxy and CDN, and a lost answer will not be retried by any client library; the 2,600 seat-map reads a second all reach the origin.
  • Using GET for a state change, such as GET /orders/123/cancel — a link prefetcher, a crawler or the browser's back button cancels the order, and each of them was told by the method that it was safe.
  • Believing PATCH is idempotent because PUT is — an incrementing patch that is retried is a double increment; idempotency belongs to the operation, not to the spelling of the method.
  • Retrying POST in the client library "for reliability" without an idempotency key — this is the double charge of Chapter 1, created by a helper with good intentions and no way to declare a repeat.
  • Rendering the full body to answer a HEAD — the client asked for headers because the body was expensive, and the service paid for the body anyway; derive the validator from a version the service already stores.
  • Answering a CORS preflight from the authentication layer — every browser on another origin gets a 401 before its real request, the error rate looks like an outage, and the buyer's page never loads.
Best Practices
  • Choose the method by its retry promise first and its English meaning second; ask who is allowed to repeat this before asking what to call it.
  • Use PUT for every upsert where the client can name the resource, and accept the free retry that comes with it.
  • Give every POST that creates or charges an Idempotency-Key, require it in the contract, and document the format alongside the endpoint.
  • Return 405 with an Allow header for unsupported methods, so a client learns the resource's contract from the error itself.
  • Serve every read with GET without exception, and turn a query too long for a URL into a saved search resource instead of a POST.
Comparable toolsFastAPI decorators, Express app.get and app.post, Spring @GetMapping: the same promises in every routerStripe and GitHub public APIs whose POSTs take idempotency keysJSON:API and OpenAPI where the promises are written down per endpointRFC 9110 the definition of safe and idempotent that every client reads

Knowledge Check

What separates a safe method from an idempotent one?

  • A safe method changes nothing on the server; an idempotent one may change state, but repeats leave it the same
  • A safe method may be stored by any cache along the way; an idempotent one may not be cached but may be retried freely
  • A safe method is one a browser is allowed to send on its own; an idempotent one is sent only by client libraries
  • A safe method changes state on the first call only; an idempotent one applies the same change again on every call

A patch that sets an event's title is idempotent in practice. Why is PATCH still classified as not idempotent?

  • Because a PATCH body is a partial document, so the server has nothing complete to compare it against
  • Because the method admits operations such as an increment, whose repeat does not produce the same state
  • Because PATCH arrived later than PUT and client libraries never added automatic retries for the newer method
  • Because a PATCH response does not return the resulting representation, so a client cannot verify what it did

The connection resets halfway through a request. Which one will a standards-following client library repeat on its own?

  • A POST that creates an order, because creating a resource is safe to attempt a second time
  • A PATCH that sets a title, because that particular patch is idempotent in practice
  • A DELETE of an expired hold, because the method promises that a repeat leaves the same state
  • None of them, because after a reset the server may already have carried out the work exactly once

A retry helper repeated POST /holds after a lost response, and the seat now has two holds rows. What does the method say about that helper?

  • It broke the contract, because a client must never repeat a POST whatever the response was
  • It should have sent a PUT instead, which would have made the second request idempotent by method
  • It was within its rights, because POST promises nothing and the repeat is the server's problem
  • It should have asked the server whether the hold existed before repeating the request

You got correct