Skip to content

Repository files navigation

idempotency-shield

A drop-in idempotency layer for FastAPI POST endpoints with the proof run in its tests: 50 concurrent duplicate payment requests, one charge in the ledger, 49 byte-identical replays, and a war-story test for the crash window that middleware-only idempotency pretends not to have.

CICoverageLicenseProof

What this solves

  • Retries are how networks work and how customers get double-charged; with the shield, sending the same Idempotency-Key twice, concurrently or hours apart, returns the same 201 with the same charge id (50-way concurrent storm asserted in tests, one charge per storm across 10 storms in the benchmark).
  • Concurrent duplicates usually get a 409 and a client-side retry loop nobody writes correctly; here losers wait for the winner and replay its exact response with Idempotent-Replay: true (ADR-001).
  • Middleware-only idempotency has a crash window (die after the side effect commits, before the response stores) that re-executes handlers on retry; this repo names the window, tests it, and closes it with a database uniqueness constraint where atomicity actually exists (ADR-002).

Why this exists

Every payment API document says "retry with the same idempotency key", and every backend team eventually learns what that costs to implement correctly: not the happy path (a Redis GET is easy) but the two hard cases. First, the storm: a mobile client on a bad connection fires the same request five times in 200 ms, all before the first one finishes; a naive check-then-set races and charges twice. Second, the crash: the server commits the charge and dies before recording the response; the client retries, the handler runs again, and the ledger has two rows for one tap.

The shield handles both, honestly. Claiming is atomic (SET NX with an in-flight TTL and a per-claimant token); completion is a Lua compare-and-set so only the claimant can store the result; concurrent losers poll until the winner's response exists and replay it byte-identically. For the crash window, the design refuses to lie: no middleware can make a Postgres commit atomic with a Redis write, so the demo payment handler carries a UNIQUE(idem_key) constraint and treats the violation as "already done", resolving to the existing charge. The test suite simulates the exact crash (record wiped after commit, retry re-executes the handler) and asserts the ledger still holds one charge. Both layers are load-bearing, and the ADR says which layer provides which guarantee.

The proof

Proof

Sequence

Tech stack

TechnologyRole in this projectWhy chosen here
Redis (SET NX + Lua CAS)Claim, wait, replay storeAtomic claim across replicas; token-guarded completion so a stale claimant cannot overwrite a winner
PostgreSQLThe demo ledger and the second guarantee layerUNIQUE(idem_key) is the only place at-most-once is actually enforceable for a transactional side effect
FastAPI decoratorIntegration surface@idempotent(store, settings) on a handler; no framework fork, no global middleware magic
httpx + asyncioStorm benchmark50-way concurrent duplicates against the real ASGI app, results asserted not eyeballed
structlogLogsCompletion rejections (stale claimants) are visible, not silent
pytestSuite6 tests: storm collapse, distinct keys, sequential replay, missing key, crash window, dead-claimant takeover
GitHub ActionsCILint, tests with Redis+Postgres service containers

Quickstart

Prerequisites: Python 3.11+, Docker (Redis + Postgres), git.

git clone https://github.com/<you>/idempotency-shield.git
cd idempotency-shield
docker run -d -p 6379:6379 redis:7
docker run -d -p 5432:5432 -e POSTGRES_USER=ledger -e POSTGRES_PASSWORD=ledger -e POSTGRES_DB=shield postgres:16
python -m venv .venv &&source .venv/bin/activate
pip install -e ".[dev]"
pytest # includes the 50-way storm and the crash-window test# use it in your own service
fromshield.middlewareimportidempotentfromshield.storeimportIdempotencyStorestore=IdempotencyStore(redis_url, key_ttl_s=86400, inflight_ttl_s=30)
@app.post("/payments")@idempotent(store, settings)asyncdefcreate_payment(request: Request, response: Response):
... # pair with UNIQUE(idem_key) in your table; ADR-002 explains why

The numbers

Measured in-process against the real app (2 vCPU container), benchmark/results/latency.json:

Pathp50p95
First-time request (claim + handler + Postgres insert + CAS)2.27 ms2.96 ms
Replay (store hit, no handler)0.65 ms0.83 ms
50-way concurrent duplicate storm, full resolution73.8 ms116 ms max

310 charges from 300 unique keys plus 10 storms of 50 duplicates each: exactly one charge per storm, asserted in the benchmark itself.

Architecture decisions

ADR-001: losers wait and replay rather than 409, and the measured cost of that kindness. ADR-002: why the shield refuses to claim exactly-once on its own, and where the second layer must live.

Intentionally out of scope

  • Request-body fingerprinting (reject same key + different body as 422). Worth adding; deliberately kept out of v1 to keep the claim semantics auditable. Trigger: first real client bug from key reuse.
  • Non-transactional side effects (emails, third-party calls). The crash window cannot be closed by a constraint there; the outbox pattern is the right tool and is its own project (txn-exactly-once-ledger).
  • Framework ports (Spring, Express). The Redis contract (claim token, Lua CAS, TTL semantics) is the spec; ports are mechanical.

Security and compliance

Keys are caller-chosen opaque strings; the store holds response bodies, so retention (default 24 h) is a data-governance knob exposed in config. In-flight TTL bounds lock lifetime, so a dead process can never wedge a key permanently. No secrets in code; connection strings from env.

Failure modes

FailureDetectionBehaviourRecovery
Duplicate stormBy designOne handler execution; losers replay the winnerTested at 50-way
Claimant dies mid-handlerIn-flight TTL expiryWaiter takes over the claim and executestest_dead_claimant_takeover
Claimant dies after commit, before CASRetry re-executes the handlerDB unique constraint resolves to the existing chargeThe war-story test; ADR-002
Redis downClaim errorsEndpoint fails closed (no unprotected execution)Redis HA in prod; the guarantee never silently degrades
Winner slower than wait timeoutWaiter 409s after wait_timeout_sBounded waiting; client retries later and hits replayConfig knob
Stale claimant completes lateLua CAS token mismatchCompletion rejected and logged, winner's response standscomplete_rejected log event

Hardest problem solved

The hardest part of this project was refusing to ship a lie that every demo of this pattern ships. The middleware works: storms collapse, replays are byte-identical, the happy path is airtight. Then I wrote the test I would want to see as a reviewer: commit the charge, wipe the idempotency record to simulate the claimant dying before completion, retry. The handler ran again. Without further defense that is a double charge, and no amount of Redis engineering fixes it, because a Postgres commit and a Redis write cannot be made atomic from the application.

The resolution is architectural honesty rather than cleverness (ADR-002): the shield's contract is replay and collapsing; at-most-once for the money lives in the ledger itself as UNIQUE(idem_key), with the handler converting the violation into "already done" and returning the existing charge id. The crash-window test now passes for the right reason, asserting both the response identity and the ledger count. The interview-ready generalization: idempotency is a property of the system, not of a layer, and each guarantee must be enforced in the store that can actually enforce it. Anyone whose middleware "guarantees exactly-once" has just not written this test yet.

Future work

  • Request-body fingerprint check (same key, different payload: 422).
  • Pub/sub wake-up replacing waiter polling for hot keys.
  • Metrics: replay rate, storm size distribution, takeover count (each is a client-health signal).
  • Spring Boot port of the store contract.
  • First metric to watch in adoption: replay rate per client. A spike is a client retry-storm bug found before it becomes a support ticket.

License

MIT

About

Drop-in idempotency layer for POST endpoints: atomic Redis claiming, byte-identical replay, in-flight collapsing. Proof asserted in tests: 50 concurrent duplicate payment requests, exactly one charge. Includes the crash-window test middleware demos hide, closed with a DB uniqueness constraint (ADR explains which layer owns which guarantee).

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages