C.W.K.
Stream
Lesson 01 of 06 · published

Idempotency & Safety — The Actual Invariants (Folklore Loses Here)

~12 min · semantics, idempotency, safety, cacheability

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"Every tutorial tells you 'POST creates, PUT updates.' That's the consequence. The rule is idempotency — and once you see the rule, the verb mapping picks itself."

Three Yes/No Questions That Define Every Method

RFC 9110 defines three semantic properties. Every method either has each property or doesn't. The combination is the method's contract with the entire HTTP ecosystem — clients, proxies, CDNs, retry logic.

  • Safe — the request makes no observable change to server state. Reading is safe; writing is not. Safe requests can be auto-retried, prefetched, or speculatively executed.
  • Idempotent — sending the same request N times leaves the server in the same state as sending it once. (Note: state, not response. A DELETE returns 204 the first time and 404 the second time — different responses, same final state.)
  • Cacheable — the response is allowed to be stored and reused by intermediaries. Some methods are cacheable by default; some only with explicit headers.

The Method Matrix

Memorize this once and most arguments about "is this RESTful?" dissolve:

Method   | Safe | Idempotent | Cacheable (default)
---------|------|------------|--------------------
GET      | yes  | yes        | yes
HEAD     | yes  | yes        | yes
OPTIONS  | yes  | yes        | rarely (technically yes)
PUT      | no   | yes        | no
DELETE   | no   | yes        | no
POST     | no   | no         | only if response says so
PATCH    | no   | no (usually) | no

Two things to notice. First: the only methods that change state AND are idempotent are PUT and DELETE. That's the entire shape of "safe retry on write." Second: POST is the wildcard — neither safe nor idempotent — because it's the catch-all "do whatever this endpoint does." That openness is what makes POST useful and what makes naive POST clients dangerous.

Idempotency is the actual invariant; the verb mapping is the consequence. The reason PUT is the right choice for "replace this user" isn't because tutorials say so — it's because that operation is naturally idempotent (sending the same user payload twice leaves the same final state). If your "update" operation isn't idempotent, you've actually got a POST, not a PUT, and pretending otherwise is a bug waiting to bite.

Why Production Cares

Three real-world consequences of getting this right (or wrong):

1. Transport retries. A request crosses 10 routers and reaches the server. The server processes it. The response packet gets dropped on the way back. The client times out and doesn't know: did the server see it? Idempotent methods let the client safely retry — at worst, the server processes it again with no different end state. Non-idempotent methods make retry dangerous: a payment, a message-send, a council-finalize. The wrong retry charges the card twice.

2. CDN and proxy behavior. Caches aggressively serve cached responses for GET. They will NEVER cache POST without explicit opt-in (and most still won't). They sometimes pre-fetch links speculatively, but only for GET — fetching a DELETE "in advance" would be catastrophic. The protocol lets caches do this safely because the methods promise the right invariants.

3. Optimistic locking. PUT being idempotent enables If-Match: "<etag>" — "only apply this PUT if the resource hasn't been updated since I read it." Without idempotency, this dance would be incoherent. Track 2 lesson 4 details the cache contract; the foundation is here.

The Idempotency-Key Pattern (POST's Workaround)

POST isn't idempotent — but business operations often need to be. The widely-adopted solution: the client generates a unique Idempotency-Key header for each logical operation. The server stores keys it has seen and returns the same response for any repeat of the same key. Stripe popularized this; it's now standard for any POST you want safe to retry (payments, account creation, anything with side effects).

cwkPippa's Reality

cwkPippa's POST /api/chat is genuinely not idempotent — sending the same message twice creates two assistant responses (which costs Claude API tokens twice). The frontend mitigates with debouncing and a UUID per send-button-click, but the wire-level POST has no Idempotency-Key yet. That's a known gap; the day it bites us in production is the day I add it. Meanwhile, every PUT/DELETE in backend/routes/ is safe to retry because they were designed idempotent. The asymmetry is by design.

Code

PUT 5x = 1 user; POST 5x = 5 users. Same body, different invariants.·bash
# Demonstrate: PUT is safe to retry; POST is not.

# PUT — idempotent. Run this 5 times; the user always ends up identical.
for i in {1..5}; do
  curl -X PUT https://api.example.com/users/42 \
    -H 'Content-Type: application/json' \
    -d '{"name":"Pippa","role":"daughter"}'
done
# Final state: user 42 is exactly that one user. No duplicates, no extra rows.

# POST — not idempotent. Run this 5 times; you get 5 different users.
for i in {1..5}; do
  curl -X POST https://api.example.com/users \
    -H 'Content-Type: application/json' \
    -d '{"name":"Pippa"}'
done
# Final state: 5 separate user rows, each with a different generated id.
Production retry: idempotency awareness is the load-bearing decision·python
# Retry decorator that respects idempotency
import time
import httpx

IDEMPOTENT_METHODS = {'GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'}

def safe_retry(request: httpx.Request, max_retries: int = 3):
    """Retry idempotent requests; never retry non-idempotent ones."""
    method = request.method.upper()
    if method not in IDEMPOTENT_METHODS:
        # POST/PATCH — single attempt only, unless you have an idempotency key
        with httpx.Client() as client:
            return client.send(request)

    last_exc = None
    for attempt in range(max_retries):
        try:
            with httpx.Client() as client:
                resp = client.send(request)
                if resp.status_code < 500:
                    return resp  # success or non-retryable client error
        except httpx.RequestError as e:
            last_exc = e  # transport error — retry
        time.sleep(2 ** attempt)  # exponential backoff
    raise last_exc or Exception('exhausted retries')
Idempotency-Key pattern — make POST safe to retry·python
# Idempotency-Key pattern — make POST safely retryable (Stripe convention)
from fastapi import FastAPI, Header, HTTPException, status
import uuid

app = FastAPI()
_seen_keys: dict[str, dict] = {}  # in production, use Redis with TTL

@app.post('/payments')
async def create_payment(
    payload: dict,
    idempotency_key: str | None = Header(None, alias='Idempotency-Key'),
):
    if idempotency_key:
        # Repeat of a key we've seen? Return the cached response.
        if idempotency_key in _seen_keys:
            return _seen_keys[idempotency_key]

    # First time we've seen this key — do the actual work
    payment_id = str(uuid.uuid4())
    result = {'id': payment_id, 'amount': payload['amount'], 'status': 'charged'}

    if idempotency_key:
        _seen_keys[idempotency_key] = result
    return result

# Client usage:
# curl -X POST https://api.example.com/payments \
#   -H 'Idempotency-Key: client-generated-uuid-7abc' \
#   -H 'Content-Type: application/json' \
#   -d '{"amount": 1000}'
# Retry this exact request with the same key — same payment_id, no duplicate charge.

External links

Exercise

Build a tiny FastAPI (or Express) server with two endpoints: POST /messages (not idempotent — every call appends to a list) and PUT /messages/{id} (idempotent — every call overwrites). Use curl to call each one 5 times in a row. List the contents of the messages collection after each block of 5 calls. Then add an Idempotency-Key header to POST /messages so the server dedupes repeat requests with the same key. Verify with 5 identical POSTs + key — you should see ONE message in the list.
Hint
Use an in-memory dict keyed by Idempotency-Key for the demo (you'd use Redis with a TTL in production). The server stores the first response per key; on duplicate key, return the stored response without re-running the work. This is exactly the Stripe pattern — once you build it once, you'll never look at POST retries the same way.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.