One servlet filter, two interchangeable stores, 18 tests (9 scenarios x 2 stores) against a real redis-server 7.0.15 and PostgreSQL 16.15, and 20 captured transcripts under output/: replay, 422 on a changed body, concurrent duplicates, expiry, the crash window and a handler slower than the lock. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b5yg8grY17JsVAJviV
idempotency
Companion code for Idempotency Keys for POST APIs in Spring Boot (Stripe-Style) with Redis and Postgres on ankurm.com.
One servlet filter puts the Idempotency-Key rules in front of POST /payments, and two interchangeable
stores sit behind it: Redis (a SET NX PX lock plus a stored response) and Postgres (one row per key, claimed
with INSERT ... ON CONFLICT, inside the same transaction as the payment). The test suite runs every scenario
against both stores and writes what it saw to output/.
| Piece | Version | Checked against |
|---|---|---|
| Spring Boot | 4.1.2 | maven-metadata.xml for spring-boot-starter-parent (4.2.0-RC2 exists but is not GA) |
| JDK | 25 | Temurin 25.0.5 |
| Redis | 7.0.15 (redis-server binary) |
redis-server --version |
| PostgreSQL | 16.15 | SHOW server_version (transcript 00) |
| IETF draft | draft-ietf-httpapi-idempotency-key-header-08, 22 Oct 2026, still an active Internet-Draft |
datatracker page |
Run it
export JAVA_HOME=/path/to/jdk-25
createdb idem # a role idem/idem owning database idem, or set IDEM_DB_URL, IDEM_DB_USER, IDEM_DB_PASSWORD
mvn -B test # 18 tests (9 scenarios x 2 stores), transcripts 01-08 written to output/
./scripts/run-all.sh # the above plus transcript 00 and the test summary
redis-server and redis-cli must be on the PATH; the tests start a throwaway server on a free port. The
tests truncate payments and idempotency_keys in the database they are pointed at. Docker was not available
where this module was written, so there are no Testcontainers; the Redis helper and the plain PostgreSQL
connection are the only things a GenericContainer would replace.
To run the application: mvn spring-boot:run (store chosen by idempotency.store=redis|postgres).
Time zone trap. The tests pin -Duser.timezone=UTC. A JVM whose zone is the legacy id Asia/Calcutta cannot
connect to this PostgreSQL at all, because pgjdbc sends the zone as a startup parameter and the server has no such
alias. If you run the application from a machine with TZ=Asia/Calcutta, start it with
-Duser.timezone=Asia/Kolkata. See scripts/timezone-trap.sh and
output/00-timezone-trap.txt.
What is where
| File | What it is |
|---|---|
IdempotencyFilter |
reads the header, fingerprints the request, asks the store, writes 400 / 409 / 422 or the (replayed) response |
IdempotencyStore |
the one interface both stores implement |
RedisIdempotencyStore |
SET NX PX lock, compare-and-set Lua scripts to finish or release it |
PostgresIdempotencyStore |
claim by INSERT ... ON CONFLICT DO UPDATE ... WHERE expired, whole request in one transaction |
CachedBodyRequest |
lets the body be read twice (fingerprint, then controller) |
PaymentController |
the operation that must not run twice; /payments/unprotected is the same code with no filter |
FaultInjector, SimulatedCrash |
make the slow-handler and crash-window failures reachable; delete both before shipping |
schema.sql |
payments and idempotency_keys |
StoreContractTest |
all nine scenarios, per-store assertions where the stores differ |
Rules the filter applies
| Situation | Response |
|---|---|
no Idempotency-Key, blank, or longer than 255 characters |
400 |
| key seen, request body (or method/path) differs | 422 |
| key seen, first request still running (Redis) | 409 with Retry-After: 1 |
| key seen, first request still running (Postgres) | waits, then replays the stored response |
| key seen, first request finished | the stored response, plus Idempotent-Replayed: true |
| handler answered 5xx | nothing stored; the key is released so the client can retry |
Captured output
| File | Scenario |
|---|---|
00-timezone-trap.txt |
psql refusing Asia/Calcutta and accepting Asia/Kolkata |
01-replay-{redis,postgres}.txt |
a retry gets the first response back; one payment row |
02-mismatch-*.txt |
same key, changed amount: 422; same JSON with extra spaces: also 422 |
03-missing-key-*.txt |
missing key and the 255 / 256 character boundary |
04a-timeout-retry-unprotected-*.txt |
a client timeout and a retry with no key: two payments |
04b-timeout-retry-keyed-*.txt |
the same story with a key: one payment (409 on Redis, a wait and a replay on Postgres) |
05-concurrent-*.txt |
ten simultaneous duplicates: one payment, nine 409s on Redis, nine replays on Postgres |
06-expiry-*.txt |
the key is forgotten after the TTL and the same key pays again |
07-crash-window-*.txt |
a crash after the payment commits: Redis ends up with two payments, Postgres with one |
08-slow-handler-*.txt |
a handler slower than the Redis lock: two payments and nothing stored; Postgres replays |
09-test-summary.txt |
surefire's own counts |
Transcript lines containing times or dates (the 500 body in 07) change between runs; everything else is
deterministic.