caduh

Idempotency — What it is and Why it Matters (especially for APIs)

5 min read

A quick, practical explainer of idempotency: what it means, how HTTP methods relate, and how to implement idempotent POSTs with idempotency keys to make retries safe.

TL;DR

  • An operation is idempotent if doing it once or many times leaves the system in the same state.
  • In HTTP, GET/PUT/DELETE are defined as idempotent; POST and PATCH are not by default.
  • Networks fail; clients retry. Make writes safe to retry using idempotency keys, unique constraints, and dedupe storage.
  • Store the first result and return it for repeats; conflict if the same key is reused with a different payload.
  • Idempotency ≠ “no side effects.” It guarantees the final state is stable across retries.

Why it matters

Retries happen: mobile clients drop, proxies time out, webhooks redeliver, queues redrive. Without idempotency you risk double charges, duplicate orders, or duplicate side‑effects. With idempotency you can freely retry (client or server) and still end up in the correct state.


HTTP semantics (speed run)

  • Safe + idempotent: GET, HEAD, OPTIONS (no state change expected).
  • Idempotent by spec: PUT (replace resource), DELETE (delete same resource repeatedly).
  • Not idempotent by default: POST, PATCH. You can make them idempotent with keys/constraints.

Idempotency cares about resource state, not response codes. Multiple DELETE /orders/123 can return 200 then 404 but the state (order is gone) is unchanged.


How to implement idempotent POST (practical)

1) Accept an Idempotency‑Key

  • Client sends a unique key per logical operation (e.g., payment attempt) in a header like Idempotency-Key: <uuid>.
  • Scope keys by actor + endpoint (e.g., user/org + path + method).

2) Record the first request & response

  • On first use of the key: process the request, store a record of request hash, status, and response body.
  • On repeat with the same request hash: return the stored response (same status/body).
  • On repeat with a different request hash: return 409 Conflict with guidance to use a new key.

3) Concurrency control

  • Use an atomic insert to “claim” a key to avoid races, then process.
  • Optionally short‑lock the key (e.g., status in_progress) so concurrent retries wait/poll.

4) TTL & storage

  • Keep records long enough to cover typical retries (minutes–days depending on use case).
  • GC old records; don’t keep forever.

Minimal schema (Postgres)

CREATE TABLE idempotency_keys (
  actor_id        text    NOT NULL,               -- user/org/app id
  method          text    NOT NULL,
  path            text    NOT NULL,
  idempotency_key text    NOT NULL,
  request_hash    text    NOT NULL,
  response_status int,
  response_body   jsonb,
  status          text    NOT NULL DEFAULT 'succeeded',  -- or 'in_progress','failed'
  created_at      timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (actor_id, method, path, idempotency_key)
);

Example: Express middleware (Node.js)

import crypto from "crypto";
import express from "express";
const app = express();
app.use(express.json());

function hashBody(body) {
  return crypto.createHash("sha256").update(JSON.stringify(body)).digest("hex");
}

app.post("/payments", async (req, res) => {
  const key = req.get("Idempotency-Key");
  if (!key) return res.status(400).json({ error: "missing_idempotency_key" });

  const scope = { actorId: req.user.id, method: "POST", path: "/payments" };
  const reqHash = hashBody(req.body);

  // Try to claim the key atomically
  const claimed = await db.idem.tryInsert(scope, key, reqHash); // returns false if exists
  if (!claimed) {
    const rec = await db.idem.find(scope, key);
    if (rec.request_hash !== reqHash) return res.status(409).json({ error: "key_reuse_conflict" });
    res.status(rec.response_status).set("Idempotency-Replayed", "true").send(rec.response_body);
    return;
  }

  try {
    const payment = await charge(req.body); // your side-effect
    const body = { id: payment.id, status: payment.status };
    await db.idem.complete(scope, key, 201, body);
    res.status(201).json(body);
  } catch (e) {
    await db.idem.complete(scope, key, 422, { error: "failed" }); // choose what to cache
    res.status(422).json({ error: "failed" });
  }
});

Notes:

  • Cache successful (and often validation‑error) responses; avoid caching transient 5xx errors.
  • Return the same response for repeats; some APIs also add Idempotency-Replayed: true for clarity (custom header).

Example: FastAPI (Python)

from fastapi import FastAPI, Header, HTTPException, Request
import hashlib, json

app = FastAPI()

def body_hash(data): 
    return hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()

@app.post("/payments")
async def payments(request: Request, idempotency_key: str = Header(None)):
    if not idempotency_key:
        raise HTTPException(status_code=400, detail="missing_idempotency_key")
    body = await request.json()
    scope = ("POST", "/payments", request.state.user_id)
    req_hash = body_hash(body)

    rec = await idem.get(scope, idempotency_key)
    if rec:
        if rec["request_hash"] != req_hash:
            raise HTTPException(status_code=409, detail="key_reuse_conflict")
        return JSONResponse(rec["response_body"], status_code=rec["response_status"], headers={"Idempotency-Replayed": "true"})

    if not await idem.claim(scope, idempotency_key, req_hash):  # atomic insert
        # someone else claimed concurrently; fetch and replay
        rec = await idem.wait_and_get(scope, idempotency_key)
        return JSONResponse(rec["response_body"], status_code=rec["response_status"], headers={"Idempotency-Replayed": "true"})

    try:
        payment = await charge(body)
        resp = {"id": payment.id, "status": payment.status}
        await idem.complete(scope, idempotency_key, 201, resp)
        return resp
    except ValidationError as e:
        await idem.complete(scope, idempotency_key, 422, {"error": str(e)})
        raise

Design tips & gotchas

  • Normalize the request before hashing (order keys, strip non‑semantic fields).
  • Scope keys narrowly: include actor, method, path (and sometimes tenant).
  • If the operation is naturally unique (e.g., order_id is a unique key), you already have idempotency—lean on a unique constraint.
  • Beware side effects outside your DB (emails, webhooks). Use an outbox table or job idempotency to avoid duplicates.
  • Exactly‑once is a myth at scale; aim for at‑least‑once with idempotent handlers.
  • Retries: use exponential backoff + jitter and cap total retry time.

Common pitfalls & fixes

| Problem | Why it hurts | Fix | |---|---|---| | Reusing key with different payload | Ambiguous intent | Return 409 Conflict; require a new key | | Key stored but request timed out | Client retries and double‑executes | Mark records in_progress; retries should poll or replay when complete | | Caching 5xx responses | Locks clients into errors | Only store success and validation outcomes | | Hashing raw JSON | Equivalent bodies hash differently | Canonicalize JSON (sort keys/strip whitespace) before hashing | | No TTL/GC | Table grows unbounded | Add TTL/index and periodic cleanup |


Quick checklist

  • [ ] Accept Idempotency‑Key on write endpoints that clients may retry.
  • [ ] Atomically claim keys and store request hash + response.
  • [ ] Replay identical repeats; 409 on mismatched repeats.
  • [ ] Use unique constraints for natural idempotency (order id, payment intent id).
  • [ ] Handle concurrency, TTL/GC, and outbox for external side‑effects.
  • [ ] Document client behavior: when to generate keys and how long results are replayed.

One‑minute adoption plan

  1. Pick endpoints at risk (payments, orders).
  2. Add Idempotency-Key + table with PRIMARY KEY (actor, method, path, key).
  3. Implement claim → process → complete with atomic insert + replay.
  4. Canonicalize request bodies for hashing.
  5. Roll out with metrics (replay rate, conflicts, storage size) and a GC job.