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:
2026-11-08 18:22:05 +00:00
co-authored by Claude Sonnet 5.5
parent 8da3374155
commit 6a36e84858
44 changed files with 1459 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
$ psql -c "SHOW server_version"
16.15 (Ubuntu 16.15-0ubuntu0.24.04.1)
$ psql -c "SET timezone='Asia/Calcutta'"
ERROR: invalid value for parameter "TimeZone": "Asia/Calcutta"
$ psql -c "SET timezone='Asia/Kolkata'"
SET
$ ls /usr/share/zoneinfo/Asia | grep -E 'Calcutta|Kolkata'
Kolkata
$ dpkg -l tzdata | tail -1
tzdata 2026e-0ubuntu0.24.04.1
@@ -0,0 +1,5 @@
store: postgres
POST /payments key=k-1 -> HTTP 201 replayed=- retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
POST /payments key=k-1 (retry) -> HTTP 201 replayed=true retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 1
what the store holds: row status COMPLETED, response_status 201
+5
View File
@@ -0,0 +1,5 @@
store: redis
POST /payments key=k-1 -> HTTP 201 replayed=- retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
POST /payments key=k-1 (retry) -> HTTP 201 replayed=true retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 1
what the store holds: string beginning "DONE", expiring: true
@@ -0,0 +1,6 @@
store: postgres
key=k-2 amount 1999 -> HTTP 201 replayed=- retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
key=k-2 amount 2999 -> HTTP 422 replayed=- retry-after=- {"title":"Idempotency-Key was already used with a different request","status":422}
key=k-2 same JSON, extra spaces -> HTTP 422 replayed=- retry-after=- {"title":"Idempotency-Key was already used with a different request","status":422}
key=k-3 amount 2999 -> HTTP 201 replayed=- retry-after=- {"id":2,"customer":"cus_42","amount":2999,"currency":"INR"}
payments in the table: 2
+6
View File
@@ -0,0 +1,6 @@
store: redis
key=k-2 amount 1999 -> HTTP 201 replayed=- retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
key=k-2 amount 2999 -> HTTP 422 replayed=- retry-after=- {"title":"Idempotency-Key was already used with a different request","status":422}
key=k-2 same JSON, extra spaces -> HTTP 422 replayed=- retry-after=- {"title":"Idempotency-Key was already used with a different request","status":422}
key=k-3 amount 2999 -> HTTP 201 replayed=- retry-after=- {"id":2,"customer":"cus_42","amount":2999,"currency":"INR"}
payments in the table: 2
@@ -0,0 +1,5 @@
store: postgres
no Idempotency-Key header -> HTTP 400 replayed=- retry-after=- {"title":"Idempotency-Key header is required for this operation","status":400}
key of 256 characters -> HTTP 400 replayed=- retry-after=- {"title":"Idempotency-Key is longer than 255 characters","status":400}
key of 255 characters -> HTTP 201 replayed=- retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 1
@@ -0,0 +1,5 @@
store: redis
no Idempotency-Key header -> HTTP 400 replayed=- retry-after=- {"title":"Idempotency-Key header is required for this operation","status":400}
key of 256 characters -> HTTP 400 replayed=- retry-after=- {"title":"Idempotency-Key is longer than 255 characters","status":400}
key of 255 characters -> HTTP 201 replayed=- retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 1
@@ -0,0 +1,4 @@
store: postgres (not used: this endpoint has no filter)
attempt 1: client gave up after 300 ms (the server is still working)
attempt 2 (the retry) -> HTTP 201 replayed=- retry-after=- {"id":2,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 2
@@ -0,0 +1,4 @@
store: redis (not used: this endpoint has no filter)
attempt 1: client gave up after 300 ms (the server is still working)
attempt 2 (the retry) -> HTTP 201 replayed=- retry-after=- {"id":2,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 2
@@ -0,0 +1,5 @@
store: postgres
attempt 1: client gave up after 300 ms (the server is still working)
attempt 2, 300 ms later -> HTTP 201 replayed=true retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
attempt 3, after the first finished -> HTTP 201 replayed=true retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 1
@@ -0,0 +1,5 @@
store: redis
attempt 1: client gave up after 300 ms (the server is still working)
attempt 2, 300 ms later -> HTTP 409 replayed=- retry-after=1 {"title":"A request is outstanding for this Idempotency-Key","status":409}
attempt 3, after the first finished -> HTTP 201 replayed=true retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 1
@@ -0,0 +1,6 @@
store: postgres
10 requests, same key, sent together, 500 ms of work each
201 created now : 1
201 replayed : 9
409 in progress : 0
payments in the table: 1
@@ -0,0 +1,6 @@
store: redis
10 requests, same key, sent together, 500 ms of work each
201 created now : 1
201 replayed : 0
409 in progress : 9
payments in the table: 1
@@ -0,0 +1,5 @@
store: postgres, ttl: 2 s
first request, key=k-6 -> HTTP 201 replayed=- retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
immediately after, key=k-6 -> HTTP 201 replayed=true retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
after sleeping 2.5 s, key=k-6 -> HTTP 201 replayed=- retry-after=- {"id":2,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 2
+5
View File
@@ -0,0 +1,5 @@
store: redis, ttl: 2 s
first request, key=k-6 -> HTTP 201 replayed=- retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
immediately after, key=k-6 -> HTTP 201 replayed=true retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
after sleeping 2.5 s, key=k-6 -> HTTP 201 replayed=- retry-after=- {"id":2,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 2
@@ -0,0 +1,7 @@
store: postgres, in-progress ttl: 2 s
attempt 1 (process dies after the write) -> HTTP 500 replayed=- retry-after=- {"timestamp":"2026-11-08T18:16:36.198Z","status":500,"error":"Internal Server Error","path":"/payments"}
payments in the table: 0
attempt 2, immediately -> HTTP 201 replayed=- retry-after=- {"id":2,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 1
attempt 3 -> HTTP 201 replayed=true retry-after=- {"id":2,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 1
@@ -0,0 +1,7 @@
store: redis, in-progress ttl: 2 s
attempt 1 (process dies after the write) -> HTTP 500 replayed=- retry-after=- {"timestamp":"2026-11-08T18:16:50.214Z","status":500,"error":"Internal Server Error","path":"/payments"}
payments in the table: 1
attempt 2, immediately -> HTTP 409 replayed=- retry-after=1 {"title":"A request is outstanding for this Idempotency-Key","status":409}
payments in the table: 1
attempt 3, after the lock expired -> HTTP 201 replayed=- retry-after=- {"id":2,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 2
@@ -0,0 +1,6 @@
store: postgres, in-progress ttl: 2 s, handler takes 3.2 s
request A (t=0 s) -> HTTP 201 replayed=- retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
request B (t=2.5 s, same key) -> HTTP 201 replayed=true retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 1
requests that finished after their lock expired: n/a (a row lock under an open transaction cannot expire)
what the store holds afterwards: row status COMPLETED, response_status 201
@@ -0,0 +1,6 @@
store: redis, in-progress ttl: 2 s, handler takes 3.2 s
request A (t=0 s) -> HTTP 201 replayed=- retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
request B (t=2.5 s, same key) -> HTTP 201 replayed=- retry-after=- {"id":2,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 2
requests that finished after their lock expired: 2
what the store holds afterwards: nothing (EXISTS idem:k-8 = 0)
+3
View File
@@ -0,0 +1,3 @@
Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 15.69 s -- in com.ankurm.idempotency.PostgresStoreTest
Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 16.31 s -- in com.ankurm.idempotency.RedisStoreTest
Tests run: 18, Failures: 0, Errors: 0, Skipped: 0