Duplicate requests: retries, double-clicks, and idempotent APIs

Scenario

A client times out on POST /payments, retries, and the user is charged twice. Or a double-click submits two orders. Mobile apps and gateways retry on 503. Message consumers deliver at least once. Without idempotency, “exactly once” behavior is impossible—you need safe retries: same logical operation produces one side effect.

After reading, you should be able to:

Why — networks guarantee at-least-once

TCP and HTTP do not tell the client whether the server finished work after a timeout. Load balancers, mobile SDKs, and service meshes retry failed requests. POST creates a new resource each time by default—retries create duplicates unless you add an application-level idempotency layer.

Sources of duplicates

HTTP methods (baseline)

MethodTypically idempotent?
GET, HEADYes (no server state change)
PUT, DELETEYes for same URL/body semantics
POSTNo — needs Idempotency-Key or natural key
PATCHDepends on implementation

What — detect and scope the problem

  1. Business symptoms — duplicate order ids, double charges, two emails, support “charged twice” with same timestamp window.
  2. Logs / traces — two POST with same user, same amount, different request_id; or same Idempotency-Key missing.
  3. DB evidence — unique constraint violations ignored; duplicate rows with no unique index on business key.
  4. Which operations need idempotency — payments, inventory hold, signup, anything irreversible or billed.
  5. Retry policy audit — client retries POST? only GET? exponential backoff on 429?

How — implement idempotent operations

1. Idempotency-Key header (standard pattern)

Client sends a UUID (per logical action, not per HTTP attempt):

POST /payments
Idempotency-Key: 7b9e2f1a-4c3d-4e5f-9a8b-1c2d3e4f5a6b
Content-Type: application/json

Server stores (key, request_hash) → response for 24–72 hours.

2. Processing flow

  1. Begin transaction (or atomic Redis + DB).
  2. Insert idempotency record with unique constraint on idempotency_key.
  3. If insert conflicts → load stored response, return same status/body (200/201).
  4. If insert succeeds → perform side effect once, save response, commit.

3. Schema sketch

CREATE TABLE idempotency_keys (
  key           VARCHAR(64) PRIMARY KEY,
  user_id       BIGINT NOT NULL,
  request_hash  VARCHAR(64),
  response_code INT,
  response_body JSONB,
  created_at    TIMESTAMPTZ DEFAULT now()
);
-- Optional: UNIQUE (user_id, key) if keys are per-user

4. Redis fast path (with DB authority for money)

SET idem:7b9e2f1a NX EX 86400
# NX fails → return cached response from DB/Redis

For payments, prefer DB unique constraint as source of truth; Redis as cache. On Redis down, fail closed for payment — Redis guide.

5. Natural keys (no header)

6. Message consumers

7. Response rules

CaseResponse
First success201 + resource
Replay same key + same bodySame as first (200/201 + same body)
Same key, different body409 Conflict
Still processing409 or 202 + Retry-After (optional)

8. Client guidance

Verify

  1. Integration test: two parallel POSTs same key → one charge, both get identical response.
  2. Chaos: kill pod after DB commit before response → client retry still idempotent.
  3. Metrics: idempotency replay rate, 409 conflict rate.

Interview one-liner

“Retries are inevitable—I use Idempotency-Key with a unique DB record, return the stored response on replay, 409 if the body differs, and the same pattern for Kafka consumers with a processed-event id. Money paths fail closed if the dedupe store is unavailable.”

Related scenarios