Files
asmhatreandClaude Sonnet 5.5 6a36e84858 Add idempotency module: Idempotency-Key for POST APIs on Spring Boot 4.1.2 with Redis and Postgres stores
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
2026-11-08 18:22:05 +00:00

5.6 KiB

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.