Idempotency Keys in a Spring Boot REST API: Atomic Claims, Request Fingerprints, and 20 Concurrent Duplicates

Code tested with Spring Boot 3.5.16, Spring Data Redis, Redis 7.4.11 (Testcontainers) and Java 17; 9 tests plus a recorded curl session

Revision note (2026-09-15). The earlier version of this article configured spring.redis.* while assuming Spring Boot 2.7 or later; on Spring Boot 3 those properties moved to spring.data.redis.* and are ignored. It injected RedisTemplate<String, Object>, which Spring Boot does not auto-configure, so the application context fails to start. Its setIfAbsent("LOCK") recipe left the lock in place for the full 24-hour TTL whenever the first attempt threw, so every retry with that key was refused for a day; it did not compare the request body, so a reused key silently returned the old response for a different payment; and its concurrent-duplicate path was an unhandled exception rather than the "retry-after error" the text promised. This version is rebuilt around a lab with 9 tests against a real Redis and a recorded curl session.

What the endpoint promises

POST /api/payments with an Idempotency-Key header:

  • No header: 400.
  • First request with a key: processed, response stored under the key with a 24 h TTL, 201.
  • Same key, same body, later: the stored status and body, plus Idempotency-Replayed: true.
  • Same key, different body: 422. The key was already used for something else.
  • Same key while the first request is still running: 409 with Retry-After: 1.
  • First request fails: the key is released so the client can retry; nothing is stored.

That is close to what Stripe documents for its API (keys up to 255 characters, pruned after 24 hours, parameters compared against the original request), minus one thing: Stripe stores failures too, and replays them. The lab does not; see the last section.

Tested versions: Spring Boot 3.5.16 (Spring Data Redis via the 2025.0.13 BOM, Lettuce 6.6.0), Redis 7.4.11 in the redis:7-alpine image through Testcontainers 1.21.4, H2 for the payments table, Java 17 (Amazon Corretto 17.0.14).

Properties: Boot 3 names

spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.timeout=2000ms

The Spring Boot 3.0 migration guide: "Configuration Properties for Redis have moved from spring.redis. to spring.data.redis.". The old names produce no error, just a connection to localhost:6379 with defaults, which is why an outdated application.yml can look like it works until the host changes.

The store: one Lua script, one JSON record

One Redis key per idempotency key, holding a small JSON record:

{"state":"IN_PROGRESS","requestHash":"a23f...","status":null,"body":null}
{"state":"DONE","requestHash":"a23f...","status":201,"body":"{"paymentId":2,"message":"Payment successful"}"}

The claim is a single script: return the existing value, or create the IN_PROGRESS record and return nil. Checking and claiming in one round trip means there is no window in which the key can expire between a failed SETNX and the GET that follows it (the earlier version had that window and an "Invalid idempotency cache state" branch to catch it; that branch is reasoned about here, not reproduced).

@Component
public class IdempotencyStore {

    static final String PREFIX = "idempotency:";

    private static final RedisScript<String> CLAIM = new DefaultRedisScript<>("""
        local existing = redis.call('GET', KEYS[1])
        if existing then
          return existing
        end
        redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
        return nil
        """, String.class);

    private final StringRedisTemplate redis;
    private final ObjectMapper json;
    private final Duration ttl;

    public IdempotencyStore(StringRedisTemplate redis, ObjectMapper json, @Value("${demo.idempotency-ttl}") Duration ttl) {
        this.redis = redis;
        this.json = json;
        this.ttl = ttl;
    }

    /** Empty when this call created the record (the caller now owns the key). */
    public Optional<IdempotencyRecord> claim(String key, String requestHash) {
        String existing = redis.execute(CLAIM, List.of(PREFIX + key),
            write(IdempotencyRecord.inProgress(requestHash)), Long.toString(ttl.toMillis()));
        return existing == null ? Optional.empty() : Optional.of(read(existing));
    }

    public void complete(String key, String requestHash, int status, String body) {
        redis.opsForValue().set(PREFIX + key, write(IdempotencyRecord.done(requestHash, status, body)), ttl);
    }

    /** Processing failed: release the key so the client can retry instead of waiting for the TTL. */
    public void release(String key) {
        redis.delete(PREFIX + key);
    }
}

StringRedisTemplate is auto-configured by Spring Boot, and so is RedisTemplate&lt;Object, Object&gt;. RedisTemplate&lt;String, Object&gt; is not. Spring's injection is generic-aware, so a constructor asking for it fails at startup; the lab pins that with an ApplicationContextRunner test:

No qualifying bean of type 'org.springframework.data.redis.core.RedisTemplate<java.lang.String, java.lang.Object>' available

Storing JSON strings also removes the Java serialization the earlier version relied on (PaymentResponse implements Serializable) and makes the record readable with redis-cli.

The controller

@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> create(@RequestHeader(value = "Idempotency-Key", required = false) String key,
                                     @RequestBody @Valid PaymentRequest request) throws JsonProcessingException {
    if (key == null || key.isBlank() || key.length() > 255) {
        return problem(HttpStatus.BAD_REQUEST, "Idempotency-Key header is required (1-255 characters)");
    }
    String requestHash = sha256(json.writeValueAsString(request));
    Optional<IdempotencyRecord> existing = store.claim(key, requestHash);
    if (existing.isPresent()) {
        return replay(existing.get(), requestHash);
    }
    try {
        PaymentResponse response = service.process(request);
        String body = json.writeValueAsString(response);
        store.complete(key, requestHash, HttpStatus.CREATED.value(), body);
        return ResponseEntity.status(HttpStatus.CREATED).contentType(MediaType.APPLICATION_JSON).body(body);
    } catch (RuntimeException ex) {
        store.release(key);
        throw ex;
    }
}

private ResponseEntity<String> replay(IdempotencyRecord record, String requestHash) {
    if (!record.requestHash().equals(requestHash)) {
        return problem(HttpStatus.UNPROCESSABLE_ENTITY, "Idempotency-Key was already used with a different request body");
    }
    if (!record.isDone()) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .header("Retry-After", "1")
            .contentType(MediaType.APPLICATION_JSON)
            .body("{"error":"A request with this Idempotency-Key is still being processed"}");
    }
    return ResponseEntity.status(record.status())
        .header("Idempotency-Replayed", "true")
        .contentType(MediaType.APPLICATION_JSON)
        .body(record.body());
}

The request hash is the SHA-256 of the JSON serialization of the bound request object, so field order in the client's JSON does not matter but any value change does. The catch is what the earlier version lacked: without release, a LOCK value outlives the failure.

What the curl session showed

Redis on a local port, gradle bootRun, processing delay set to 300 ms so that concurrent duplicates overlap:

$ BODY='{"amount":49.90,"recipient":"ACME"}'

$ curl -s -w "nHTTP %{http_code}n" -X POST localhost:8092/api/payments -H "Content-Type: application/json" -d "$BODY"
{"error":"Idempotency-Key header is required (1-255 characters)"}
HTTP 400

$ curl -s -D - -X POST localhost:8092/api/payments -H "Content-Type: application/json" -H "Idempotency-Key: order-1001" -d "$BODY"
HTTP/1.1 201
{"paymentId":1,"message":"Payment successful"}

$ curl -s -D - -X POST localhost:8092/api/payments -H "Content-Type: application/json" -H "Idempotency-Key: order-1001" -d "$BODY"
HTTP/1.1 201
Idempotency-Replayed: true
{"paymentId":1,"message":"Payment successful"}

$ curl -s -w "nHTTP %{http_code}n" -X POST localhost:8092/api/payments -H "Content-Type: application/json" -H "Idempotency-Key: order-1001" -d '{"amount":1.00,"recipient":"ACME"}'
{"error":"Idempotency-Key was already used with a different request body"}
HTTP 422

Twenty requests at once with one key:

$ seq 1 20 | xargs -P 20 -I{} curl -s -o /dev/null -w "%{http_code}n" -X POST localhost:8092/api/payments 
    -H "Content-Type: application/json" -H "Idempotency-Key: order-2002" -d "$BODY" | sort | uniq -c
   1 201
  19 409

$ redis-cli GET idempotency:order-2002
{"state":"DONE","requestHash":"a23f952661f5b39503e2b2518058b45aabe6897bcc8ef1ef4c130f1418556122","status":201,"body":"{"paymentId":2,"message":"Payment successful"}"}
$ redis-cli TTL idempotency:order-2002
86400

The JUnit version of this (twentyConcurrentDuplicatesCreateExactlyOnePayment) releases 20 client threads on a latch and asserts exactly one new row, every response either 201 or 409, all 409s carrying Retry-After, and a later request replaying with the header. It observed the same 1 and 19 split.

A failing first attempt (recipient: FAIL makes the service throw before saving):

$ curl -s -o /dev/null -w "HTTP %{http_code}n" -X POST localhost:8092/api/payments -H "Content-Type: application/json" -H "Idempotency-Key: order-3003" -d '{"amount":5.00,"recipient":"FAIL"}'
HTTP 500
$ redis-cli EXISTS idempotency:order-3003
0
$ curl -s -w "nHTTP %{http_code}n" -X POST localhost:8092/api/payments -H "Content-Type: application/json" -H "Idempotency-Key: order-3003" -d "$BODY"
{"paymentId":3,"message":"Payment successful"}
HTTP 201

The earlier recipe, reproduced

The lab keeps the earlier control flow as ArticleStylePaymentService: setIfAbsent(key, &quot;LOCK&quot;, 24h), process, set(key, response, 24h). Test aFailureLeavesTheLockForTheFullTtlAndEveryRetryIsRejected calls it with a failing request, then checks Redis and calls again with a good one:

after the failed attempt the LOCK entry is still there, TTL seconds = 86400
IllegalStateException: Duplicate request is processing, please retry shortly

The earlier text listed "stale locks" as something that happens when "a process crashes mid-operation" and offered the TTL as the safeguard. An ordinary exception is enough, and the TTL safeguard means the customer can retry tomorrow.

What this does not cover

  • Waiting instead of 409. A client that gets 409 must retry after Retry-After; the server does not hold the second request until the first finishes.
  • Key scope. The key space here is global. A real API should namespace the Redis key by the authenticated caller so two clients cannot collide or read each other's replay.
  • Atomicity between the payment row and the Redis record. payments.save and store.complete are two writes; if the process dies between them, the key stays IN_PROGRESS until the TTL expires while the payment exists once. That window is described, not closed; closing it needs an outbox or a transactional store for the record.
  • Replaying failures. Only 201 responses are stored; a 500 releases the key. Stripe stores and replays failures too; whether you want that depends on whether your failures are retryable.
  • Redis unavailability. A Redis error fails the request; there is no fallback to a database-based key table.

Reproduce it

gradle test --no-daemon                     # 9 tests; needs Docker for the Redis container
docker run --rm -p 127.0.0.1:8093:6379 redis:7-alpine
gradle bootRun --no-daemon --args='--spring.data.redis.port=8093'
curl -s -D - -X POST localhost:8092/api/payments -H "Content-Type: application/json" -H "Idempotency-Key: k1" -d '{"amount":49.90,"recipient":"ACME"}'

Sources