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
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user