PrepZone Logo
PrepZone

Idempotency Keys and Safe Retries

Same transfer request twice must not debit twice — idempotency keys at the API gateway.

Why this matters

  • Payment APIs without idempotency double-charge when mobile clients retry POST on gateway timeout.
  • At-least-once messaging and HTTP retries both require deduplication at the server — the key is the contract.
  • ShardPay requires Idempotency-Key on all POST /transfers; keys expire after 24h with stored response hash.
  • Interviewers walk through timeout-after-commit scenarios and ask how you detect conflicting payloads on the same key.
Client retryIdempotency-Key: uuid
Dedup storeLookup before execute
Ledger write
Same key + same payload returns the original result — safe client retries after timeout.

Server-side deduplication flow

First request with key idem-7f3a executes the transfer, persists ledger rows, and stores (key → response body hash, transferId, status) in Postgres. Retry with the same key and identical body returns the stored response without re-debiting. Same key with different amount or accounts returns 409 Conflict — the client reused a key incorrectly.

ShardPay processes idempotency check inside the same DB transaction as the ledger write when possible, so crash between transfer and store cannot create a committed transfer with no dedup record.

Walkthrough: mobile retry after 504

User submits $500 transfer; gateway times out after ledger commit but before response reaches phone. App retries with same Idempotency-Key. ShardPay's store hits on key, returns 201 with original transferId and body — user sees success, account debited once. Support logs show two HTTP requests, one ledger mutation.

Key points

  • Idempotency-Key header — client-generated unique token (UUID recommended) sent on mutating requests. ShardPay rejects POST /transfers without the header using 428 Precondition Required so no production client ships without key generation.
  • Store-and-return — persist successful response metadata keyed by idempotency token before returning to client. ShardPay stores JSON response, transferId, and SHA-256 of canonical request body for conflict detection.
  • In-flight deduplication — concurrent duplicate requests with the same key must not double-execute. ShardPay uses INSERT ... ON CONFLICT DO NOTHING on the idempotency row as a claim lock; second request waits or polls until first completes.
  • Key TTL — expire old keys to bound table size (ShardPay: 24 hours). After TTL a retry with the same key is treated as a new request — document TTL in API docs so clients generate fresh keys for new business operations.
  • Conflict detection — same key plus different payload hash returns 409 instead of silently returning the first response. Prevents a bug where client reuses key for two different payees and believes the second succeeded.

Idempotency beyond HTTP

Kafka consumers at ShardPay dedupe by eventId header in a processed-events table — same semantics as HTTP keys. Database upserts use natural keys: UPDATE balance SET ... WHERE transfer_id = :id applied twice is safe when transfer_id is unique.

Idempotency is not automatic — INSERT without unique constraint duplicates rows. Every mutating code path needs an explicit key: HTTP header, event ID, or primary key constraint.

Walkthrough: concurrent duplicate requests

Two tabs submit the same transfer with identical idempotency key within 50ms. Both hit ShardPay; first transaction inserts idempotency claim and executes ledger. Second blocks on claim row lock, then reads completed response and returns without second debit. User sees one transfer in history.

Java
// ShardPay idempotency store — transactional dedup (Java 17)
@Transactional
public TransferResult transfer(TransferRequest req, String idempotencyKey) {
    byte[] payloadHash = hash(canonicalJson(req));

    Optional<IdempotencyRecord> existing = store.find(idempotencyKey);
    if (existing.isPresent()) {
        IdempotencyRecord record = existing.get();
        if (!Arrays.equals(record.payloadHash(), payloadHash)) {
            throw new IdempotencyConflictException(idempotencyKey);
        }
        return record.response();
    }

    store.insertClaim(idempotencyKey, payloadHash); // unique constraint — second caller waits

    TransferResult result = ledger.execute(req);
    store.complete(idempotencyKey, result, payloadHash, Duration.ofHours(24));
    return result;
}

// Kafka consumer — same pattern with eventId
public void onSettlementEvent(ConsumerRecord<String, SettlementEvent> record) {
    String eventId = header(record, "eventId");
    if (processedEvents.markIfAbsent(eventId, Duration.ofDays(7))) {
        settlementService.apply(record.value());
    }
}

Designing keys with retries and sagas

Saga compensating steps need their own idempotency keys per step — not the same key as the forward transfer. ShardPay uses idem-{clientRequestId}-capture and idem-{clientRequestId}-refund so partial saga retries do not collide.

Keys should be generated once per user intent at the client, not per HTTP attempt. Retries reuse; new user taps "pay again" get a new key.

Quick recall

Everything you need if you only revisit this box.

  1. Idempotency keys make HTTP and message retries safe — store response before acknowledging client.
  2. Detect conflicts: same key, different payload → 409, not silent wrong success.
  3. Use claim locks for concurrent duplicates; TTL bounds storage; sagas need per-step keys.

Test yourself

Answer these before moving on — recall is what makes it stick.