Topic 12.4
Idempotency Keys: Stopping Double Payments
In one line
Clients send an Idempotency-Key with each operation; the server records the key before processing and stores the response, so retries and double clicks return the original result instead of repeating the operation. Redis makes the fast path cheap; the database makes it correct.
Think of it like this
A cloakroom ticket. If you hand in your coat twice with the same ticket number, the attendant says "already have it" and gives you the same ticket back, instead of taking a second coat.
Key ideas
- 01
Flow: the client generates a unique key per logical operation (UUID) and resends the same key on retries. The server tries
SET idem:{key} PROCESSING NX EX 86400. If it succeeds, this request owns the operation; process it, then store the response (SET idem:{key} <response JSON> EX 86400). IfNXfails, another request has the key: if the stored value is a response, return it; if it's stillPROCESSING, return 409 or wait briefly. - 02
Why not
GETthen process: two requests 20 ms apart can both see "missing" and both process.SET NXmakes the claim atomic. - 03
Bind the key to the request: store a hash of the request body with the key; a retry with the same key but a different body is a client bug, so reject it (422).
- 04
Failure cases: the server crashes after charging but before storing the response, leaving
PROCESSINGuntil TTL. A retry then waits or gets 409; the payment provider's own idempotency key (pass yours through) prevents a second charge. When Redis is lost (failover without the key), a retry could reprocess, so the database needs its own guarantee: a unique constraint on the idempotency key in the payments table. - 05
Exactly-once is an illusion across networks: what you build is at-least-once delivery plus idempotent processing, which is effectively-once.
Code & diagrams
public ResponseEntity<PaymentResponse> pay(String key, PaymentRequest req) {
String redisKey = "idem:pay:" + key;
String fingerprint = sha256(req);
Boolean claimed = redis.opsForValue()
.setIfAbsent(redisKey, "PROCESSING:" + fingerprint, Duration.ofHours(24));
if (!Boolean.TRUE.equals(claimed)) {
String v = redis.opsForValue().get(redisKey);
if (v == null) return ResponseEntity.status(409).build(); // expired mid-flight
if (v.startsWith("PROCESSING:")) return ResponseEntity.status(409).build();
StoredResponse s = json.read(v, StoredResponse.class);
if (!s.fingerprint().equals(fingerprint)) return ResponseEntity.unprocessableEntity().build();
return ResponseEntity.status(s.status()).body(s.body()); // replay original result
}
// The DB has a UNIQUE(idempotency_key) constraint: the real correctness guarantee.
PaymentResponse result = payments.charge(req, key); // passes key to the provider too
redis.opsForValue().set(redisKey, json.write(new StoredResponse(200, result, fingerprint)),
Duration.ofHours(24));
return ResponseEntity.ok(result);
}Interview problem
The problem
PAY, PAY within 20 ms
A user double-clicks PAY and two requests with the same idempotency key arrive 20 ms apart. Design the protection. Follow-up: what if Redis crashes in the middle?
You're given
- Payments API
- Clients send Idempotency-Key
- Must never charge twice
The interviewer follows up
Why not rely on Redis alone?
When it breaks
Idempotency check done with GET-then-process
What you see
Two concurrent requests both see no key and both charge the card.
Fix & prevent
Claim with SET NX atomically, and back it with a database unique constraint.
Explain it without notes
Why is exactly-once processing an illusion, and what do you build instead?
Practice
Build idempotency middleware for one POST endpoint and test concurrent duplicates with 10 parallel requests using the same key.
Trade-offs
- ↔
Longer idempotency TTLs protect late retries but use more memory; 24 hours is common for APIs.
Done when you can
I can implement idempotency keys with an atomic claim and stored responses.
I back Redis-based idempotency with database constraints for money.