# idempotency Companion code for **Idempotency Keys for POST APIs in Spring Boot (Stripe-Style) with Redis and Postgres** on [ankurm.com](https://ankurm.com/idempotency-keys-spring-boot-redis-postgres/). 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 ```bash 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`](scripts/timezone-trap.sh) and [`output/00-timezone-trap.txt`](output/00-timezone-trap.txt). ## What is where | File | What it is | |---|---| | [`IdempotencyFilter`](src/main/java/com/ankurm/idempotency/IdempotencyFilter.java) | reads the header, fingerprints the request, asks the store, writes 400 / 409 / 422 or the (replayed) response | | [`IdempotencyStore`](src/main/java/com/ankurm/idempotency/IdempotencyStore.java) | the one interface both stores implement | | [`RedisIdempotencyStore`](src/main/java/com/ankurm/idempotency/RedisIdempotencyStore.java) | `SET NX PX` lock, compare-and-set Lua scripts to finish or release it | | [`PostgresIdempotencyStore`](src/main/java/com/ankurm/idempotency/PostgresIdempotencyStore.java) | claim by `INSERT ... ON CONFLICT DO UPDATE ... WHERE expired`, whole request in one transaction | | [`CachedBodyRequest`](src/main/java/com/ankurm/idempotency/CachedBodyRequest.java) | lets the body be read twice (fingerprint, then controller) | | [`PaymentController`](src/main/java/com/ankurm/idempotency/PaymentController.java) | the operation that must not run twice; `/payments/unprotected` is the same code with no filter | | [`FaultInjector`](src/main/java/com/ankurm/idempotency/FaultInjector.java), [`SimulatedCrash`](src/main/java/com/ankurm/idempotency/SimulatedCrash.java) | make the slow-handler and crash-window failures reachable; delete both before shipping | | [`schema.sql`](src/main/resources/schema.sql) | `payments` and `idempotency_keys` | | [`StoreContractTest`](src/test/java/com/ankurm/idempotency/StoreContractTest.java) | 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`](output/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.