Senior 11 min · March 06, 2026

Idempotency — How a Missing Key Doubled Customer Charges

Duplicate orders from retried POSTs without idempotency keys cost customers twice.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Idempotency guarantees retries produce the same result as the first attempt
  • HTTP methods GET, PUT, DELETE are idempotent; POST is not by default
  • Idempotency keys (Idempotency-Key header) make POST operations safe to retry
  • Store processed keys with the full response to avoid side effects on duplicates
  • Performance cost: one extra storage lookup per request, negligible compared to duplicate chargebacks
  • Always include request method and path in the lookup to prevent cross-endpoint collisions
Plain-English First

Imagine you're pressing the elevator button for floor 5. Whether you press it once or ten times in frustration, the elevator still only comes to floor 5 once — pressing it more doesn't summon ten elevators. That's idempotency: doing the same action multiple times produces the same result as doing it once. In API terms, if your network hiccups and your app accidentally fires the same 'place order' request three times, an idempotent API makes sure you only get charged and only receive one order.

Every distributed system lives with one uncomfortable truth: networks lie. Requests time out. Connections drop. Load balancers retry. Your mobile client sends a payment request, the server processes it, but the response never makes it back. The client, seeing no answer, tries again. Now you've potentially charged someone twice for one purchase. This isn't a theoretical edge case — it's one of the most common and costly bugs in production. Idempotency is the engineering principle that prevents it.

Idempotency solves the retry problem. When an operation is idempotent, clients can safely resend a request any number of times without worrying about duplicating side effects. The server recognises a repeated request and returns the same outcome it gave the first time, rather than blindly executing again. This shifts the complexity from the client — which shouldn't have to agonise over whether to retry — to the server, which is the right place to handle it.

By the end of this article you'll understand exactly which HTTP methods are idempotent and why, how to implement idempotency keys for non-idempotent operations like payments, what to store server-side to make retries safe, and the subtle mistakes that make developers think they've built idempotency when they haven't. You'll be able to design APIs that stay correct even when the network doesn't cooperate.

What is Idempotency in API Design?

Idempotency means that sending the same request multiple times produces the same server state as sending it once. The key is server state, not response code. A DELETE that returns 404 on second call is still idempotent because the resource is still gone. In production, retries happen all the time — network timeouts, client errors, load balancer health checks. If your API isn't idempotent, every retry risks duplicating side effects.

Think of it this way: idempotency is the server's responsibility to handle retries safely. Clients shouldn't have to track which requests succeeded. They just need to know they can resend until they get a valid response. This shifts the complexity to where it belongs — the server.

The elevator button analogy works but misses a subtle point: elevators are naturally idempotent because the control system tracks which floor is already selected. Your API needs to do the same for every mutation operation. That's what idempotency keys provide.

A payment API without idempotency is a support ticket factory. Every duplicate charge triggers a refund request, eats into margins, and erodes customer trust. That's why Stripe and PayPal require an Idempotency-Key header on every charge request. It's not optional — it's the difference between a reliable system and a constant firefighting operation.

io/thecodeforge/idempotency/AccountUpdateService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package io.thecodeforge.idempotency;

import java.util.concurrent.ConcurrentHashMap;

/**
 * Demonstrates an idempotent account update using a version token.
 */
public class AccountUpdateService {
    private final ConcurrentHashMap<String, Account> store = new ConcurrentHashMap<>();

    public Account updateAccount(String accountId, AccountUpdateRequest request, long expectedVersion) {
        Account existing = store.get(accountId);
        if (existing == null) {
            throw new IllegalArgumentException("Account not found: " + accountId);
        }
        // Idempotency: if the version matches, the update has already been applied.
        if (existing.version == expectedVersion) {
            return existing;
        }
        // Version mismatch means a new update; apply it.
        Account updated = new Account(accountId, request.name, request.email, expectedVersion + 1);
        store.replace(accountId, existing, updated);
        return updated;
    }

    static class Account {
        final String id;
        final String name;
        final String email;
        final long version;

        Account(String id, String name, String email, long version) {
            this.id = id;
            this.name = name;
            this.email = email;
            this.version = version;
        }
    }

    static class AccountUpdateRequest {
        final String name;
        final String email;

        AccountUpdateRequest(String name, String email) {
            this.name = name;
            this.email = email;
        }
    }
}
Output
Calling updateAccount twice with the same expectedVersion returns the same Account on subsequent calls.
Forge Tip:
Type this code yourself rather than copy-pasting. The muscle memory of writing it will help it stick.
Production Insight
Without version checking, a retry can apply stale data over a newer update.
The version token acts as an optimistic lock, preventing lost updates.
Rule: always check the version before applying any retriable update.
Key Takeaway
Idempotency means the same request produces the same result.
Retries are safe only if the server can detect duplicates.
A version token or idempotency key is the mechanism to detect them.
Is Your Operation Idempotent?
IfOperation is a read (no state change)
UseNaturally idempotent; no special handling needed.
IfOperation replaces entire resource
UseIdempotent if the replacement is deterministic (same input → same state).
IfOperation is additive (e.g., increment, append)
UseNot idempotent; must use an idempotency key.
IfOperation creates a new resource
UseNot idempotent; idempotency key required.
IfOperation is a deletion
UseIdempotent; second call gives 404 but state unchanged.

HTTP Method Idempotency: Which Methods Are Safe to Retry?

The HTTP specification defines idempotency for methods, but many developers misinterpret it. GET, HEAD, PUT, DELETE, OPTIONS, and TRACE are idempotent by definition. POST, PATCH, and CONNECT are not. Crucially, idempotency in HTTP refers to the server's side effects from multiple identical requests, not the response codes. A PUT that creates a new resource if it doesn't exist is idempotent: calling it twice creates exactly one resource (the second call replaces it). A DELETE returns 404 on the second call, but the server state is unchanged — that's idempotent.

The danger zone is when developers assume idempotency based on method alone. For example, a PUT endpoint that increments a counter violates idempotency. Similarly, a PATCH that applies partial updates can be non-idempotent if the operation is not designed as an absolute change (e.g., "add 5 to balance" is not idempotent; "set balance to 100" is).

Always verify the actual side effects under retry scenarios. A good mental model: ask yourself "If I send this exact request twice, will the server's final state be identical to sending it once?" If yes, the operation is idempotent. For PATCH, use JSON Patch or merge patch semantics that replace fields entirely, not incrementally.

There's another trap: HEAD and OPTIONS are rarely used in practice, but they're idempotent by spec. Don't rely on them for state mutations — they shouldn't have any.

Many teams assume PATCH with JSON Patch is always idempotent. JSON Patch operations like "replace" are idempotent, but "add" to an array is not. Read the spec carefully — the operation type matters, not just the PATCH method.

io/thecodeforge/idempotency/OrderService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package io.thecodeforge.idempotency;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Idempotent order creation using Idempotency-Key.
 */
public class OrderService {
    private final Map<String, OrderResult> processedKeys = new ConcurrentHashMap<>();

    public OrderResult createOrder(String idempotencyKey, OrderRequest request) {
        // If key already processed, return cached result
        OrderResult cached = processedKeys.get(idempotencyKey);
        if (cached != null) {
            return cached;
        }
        // Process the order (e.g., insert into DB, charge payment)
        OrderResult result = doCreateOrder(request);
        // Store the result atomically
        processedKeys.put(idempotencyKey, result);
        return result;
    }

    private OrderResult doCreateOrder(OrderRequest request) {
        // Actual creation logic
        return new OrderResult("ORD-" + System.currentTimeMillis(), "created");
    }

    record OrderResult(String orderId, String status) {}
    record OrderRequest(String customerId, double amount) {}
}
Output
Calling createOrder twice with the same idempotencyKey returns the same OrderResult without creating a second order.
PATCH Idempotency Trap
PATCH with JSON Patch is only idempotent when all operations are absolute replacements. The 'add' operation on an array is not idempotent — it appends the same element twice. Use 'replace' instead.
Production Insight
The code above stores results in a ConcurrentHashMap — single-instance only.
In multi-server setups, use Redis with SET NX and EXPIRE for atomicity.
A common bug: two concurrent requests both pass the check. Use atomic primitives.
Key Takeaway
HTTP method is a hint, not a guarantee.
Always verify side effects under retry.
For non-idempotent operations, an idempotency key is mandatory.
Choosing an Idempotency Strategy
IfOperation is read-only (GET)
UseNaturally idempotent; no special handling needed.
IfOperation is a full replacement (PUT)
UseIdempotent if you replace the entire resource; use compare-and-swap for consistency.
IfOperation is a deletion (DELETE)
UseIdempotent; ignoring missing resources is fine.
IfOperation creates a new resource (POST)
UseNot idempotent; must use an idempotency key generated by the client.
IfOperation is a partial update (PATCH)
UseIdempotent only if the operation is an absolute set; incremental operations require idempotency keys.

Implementing Idempotency Keys: Generation, Transmission, and Storage

An idempotency key is a unique identifier that the client generates and sends with each request. The server uses the key to detect duplicates. The key must be globally unique (UUID v4 is standard) and must be generated client-side before the first attempt. Never let the server generate the key — if the request times out before the server responds, the client won't know the key and can't safely retry.

Transmission: Use the Idempotency-Key header. The server extracts it before processing. If the header is missing for a POST endpoint that requires idempotency, return 400 Bad Request. This forces clients to be explicit about retry safety.

Storage: Store the key along with the response status, body, and headers. The storage system must support atomic check-and-set. Redis with SET NX is the most common choice. In a relational database, use a table with a unique constraint on the key column. The response is cached until a TTL (typically 24 hours) to cover retry windows. After TTL expires, the key can be reused, but clients should not retry beyond that window. The TTL must be longer than the maximum expected retry duration.

But a subtle detail: the same key must not be reused for different operations. Always include the request method and path in the lookup key to prevent cross-endpoint collisions. For example, a key used for a payment should not accidentally match a key used for a refund.

Also, consider using ULID instead of UUID v4 if you need time-ordered keys for range scans in your storage. UUID v4 is random, which can cause index fragmentation in databases. ULID preserves sortability while maintaining uniqueness.

idempotency_store.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
-- PostgreSQL schema for idempotency store
CREATE TABLE idempotency_keys (
    idempotency_key VARCHAR(255) PRIMARY KEY,
    request_method VARCHAR(10) NOT NULL,
    request_path VARCHAR(255) NOT NULL,
    response_status INTEGER NOT NULL,
    response_body TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT NOW(),
    expires_at TIMESTAMP NOT NULL
);

-- Composite index for key + method + path to support multi-endpoint usage
CREATE INDEX idx_idempotency_method_path ON idempotency_keys (idempotency_key, request_method, request_path);
Output
Table created with a unique constraint on idempotency_key, preventing duplicate insertions.
Common Mistake: Missing Method/Path in Key
If you store only the idempotency key without method and path, a client could reuse the same key for different endpoints and get a cached response from the wrong operation. Always composite the lookup.
Production Insight
A common mistake is not including the request method and path in the lookup.
The same idempotency key should only be valid for the same operation.
Another pitfall: returning 200 with a cached response even when the request body changes.
Key Takeaway
Client generates the key, server enforces uniqueness.
Store the full response alongside the key.
TTL must exceed the maximum retry window.
Choosing Idempotency Key Storage
IfSingle-instance, low throughput
UseConcurrentHashMap with putIfAbsent is sufficient.
IfMulti-instance, high throughput
UseRedis SET NX with Lua scripting for atomic check-and-set.
IfNeed ACID guarantees
UseRelational database with unique constraint and INSERT ON CONFLICT.
IfDistributed across regions
UseUse a globally distributed store like DynamoDB or Aurora with consistent reads.

Concurrency and Race Conditions: Protecting Idempotency Under Load

The hardest part of idempotency is handling concurrent requests with the same key. Imagine a client sends a request, but the server's response is delayed. The client times out and retries. Both requests arrive at the server at nearly the same time. Without atomicity, both pass the idempotency check and both execute the operation, causing duplicates.

To prevent this, the check and store must be atomic. In Redis, use SET NX with the key name and an expiry. If SET returns OK, this request is the first to claim the key; proceed with the operation. If it returns nil, another request already claimed the key; wait for the first request to complete and retrieve its cached response. In SQL, use INSERT ... ON CONFLICT DO NOTHING and check the number of rows affected.

For even tighter guarantees, use distributed locks (e.g., Redlock) around the idempotency check for critical operations like payment processing. But locks add latency and increase complexity. Evaluate whether your business logic can tolerate a small window of potential duplicates before implementing locking.

Also consider: what if the first request fails after storing the key? You need a strategy to release the lock on failure. The simplest approach is to set a very short pre-processing state (e.g., "processing") and delete the key on error. But if the server crashes mid-processing, you'll leave a stale key. Mitigate by setting a short initial TTL (e.g., 10 seconds) and extending it once processing completes.

Using distributed locks adds 10–50ms of latency per operation. For high-throughput systems processing millions of requests per day, that overhead adds up fast. Prefer atomic database operations or Redis SET NX over locking unless the cost of a duplicate is astronomical (e.g., financial settlements).

redis_idempotency.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import redis
import uuid
import time
import json

r = redis.Redis(host='localhost', port=6379, db=0)

def create_order(idempotency_key: str, payload: dict):
    # Atomic check-and-set using SET NX
    key = f"idempotency:{idempotency_key}"
    acquired = r.setnx(key, "processing")
    if not acquired:
        # Key already exists: wait for result and return cached response
        while True:
            state = r.get(key)
            if state and state != b"processing":
                return state.decode('utf-8')
            time.sleep(0.01)  # busy wait; in production use pub/sub or polling with backoff
    try:
        # Process the order (mock)
        order_id = str(uuid.uuid4())
        result = {"order_id": order_id, "status": "created"}
        r.set(key, json.dumps(result))
        r.expire(key, 86400)  # 24 hours TTL
        return result
    except Exception as e:
        r.delete(key)  # clean up on failure
        raise
Output
Only one request will acquire the lock; others wait for the result.
Mental Model: The Room Key
  • SET NX is like taking the room key. If you get it, you're in.
  • If someone else has it, you wait at the door until they leave the result.
  • TTL is the checkout time — after that, the room is available again.
Production Insight
We encountered a race condition where two requests with the same key arrived within 5ms.
Both passed the SELECT check before either inserted. Switched to INSERT ON CONFLICT DO NOTHING.
In Redis, SET NX with EXPIRE in a single eval script guarantees atomicity.
Key Takeaway
Idempotency enforcement must be atomic.
Application-level check-then-set is not safe under concurrency.
Use database or Redis atomic primitives.
Atomic Idempotency Check Strategy
IfLow concurrency (< 100 req/s per key)
UseConcurrentHashMap with putIfAbsent is sufficient for single-instance.
IfHigh concurrency, single database
UseUse SQL INSERT IGNORE or ON CONFLICT DO NOTHING.
IfDistributed system, high throughput
UseRedis SET NX with Lua scripting for atomic check-and-set.
IfCritical financial transaction
UseCombine idempotency key with a distributed lock (e.g., Redlock) around the entire operation.

Production Pitfalls: TTL, Cleanup, and Error Responses

Idempotency keys don't live forever. They need a TTL (Time To Live) that covers the maximum expected retry window. Common choices are 24 hours for normal APIs and up to 7 days for payment-related endpoints. After TTL, the key can be reused — but a client that retries after the TTL might create a duplicate. Mitigate this by logging any late retry and considering a dead-letter queue.

Storage cleanup is essential. In Redis, TTL is handled automatically. In SQL, schedule a job to delete expired rows. Without cleanup, the table grows unbounded and performance degrades. Partitioning by creation date helps.

Error responses matter: if the idempotency key is missing or invalid, return 400 Bad Request. If a request arrives after the key has expired, return 429 Too Many Requests with a Retry-After header explaining the retry window. If the incoming request body differs from the stored request for the same key, return 422 Unprocessable Entity. These clear semantics prevent clients from misinterpreting failures.

One more trap: compressing the response body before caching. If you cache a compressed response, make sure the content-encoding header is also stored. Serving a compressed response to a client that expects uncompressed data will break parsing.

Choosing the right TTL is a trade-off: too short and you risk duplicate processing, too long and you waste storage. For most APIs, 24 hours is the sweet spot. For payment APIs, 7 days aligns with chargeback windows. Add a 1-hour grace period where expired keys are kept for audit logging but not used for deduplication — this helps in forensic investigations.

cleanup_job.shBASH
1
2
3
4
5
6
7
8
#!/bin/bash
# Cleanup expired idempotency keys from PostgreSQL
# Run this as a cron job every hour

PGPASSWORD=your_password psql -h localhost -U app -d idempotency_db -c 
"DELETE FROM idempotency_keys WHERE expires_at < NOW();"

echo "Deleted expired keys"
Output
Expired rows are deleted, keeping the table size bounded.
Forge Tip: TTL Length
Set TTL to 24 hours for most APIs. For payment endpoints, use 7 days to cover chargeback windows. Always add a grace period of 1 hour for audit logging.
Production Insight
A well-known airline booking API had a 60-second TTL on idempotency keys.
A retry after 65 seconds passed the expired-key check and created a duplicate booking.
Rule: set TTL to at least 24 hours and add a grace period for audit logging.
Key Takeaway
TTL must be longer than the maximum retry window.
Cleanup is mandatory — no unbounded storage.
Return meaningful error responses for key violations.
Choosing TTL Duration
IfStandard APIs (orders, user creation)
Use24 hours TTL.
IfPayment or financial operations
Use7 days TTL to cover chargeback windows.
IfLong-running operations (hours)
UseTTL = max expected processing time + 1 hour buffer.
IfLow throughput / audit requirement
UseLonger TTL or archival with dead-letter queue.

Idempotency in Event-Driven and Asynchronous Systems

Idempotency doesn't stop at synchronous HTTP APIs. In event-driven systems, a single event can be delivered multiple times (at-least-once semantics). Message queues like Kafka and SQS guarantee delivery but not deduplication. Your consumer must handle duplicate events idempotently.

The pattern is similar: use a deduplication identifier in the event payload (e.g., event_id). Before processing, check if that ID has been processed. Store processed IDs in a database or cache with a TTL that covers the event retention period. This is especially important for side-effecting events like "payment captured" or "order fulfilled".

A key difference: in event-driven systems, you often don't have a client to retry — the system retries automatically if processing fails. So your deduplication window must be longer than the total retry duration across all retry attempts. Using an infinite retention policy is possible but costly. Practical approach: store processed events for at least 7 days, and use a background job to archive older entries.

Important: ensure the dedup check and the event processing happen in the same transaction if possible. In Kafka, you can store the offset with the dedup key to enable exactly-once semantics. Otherwise, an event processed but the offset not committed could lead to double processing after a rebalance.

io/thecodeforge/idempotency/EventConsumer.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import redis
import json

r = redis.Redis(host='localhost', port=6379, db=0)

def process_event(event: dict):
    event_id = event['event_id']
    dedup_key = f"dedup:event:{event_id}"
    
    # Atomic dedup check
    if r.setnx(dedup_key, "1"):
        r.expire(dedup_key, 86400 * 7)  # 7 day TTL
        # Process event
        handle_event(event)
    else:
        # Already processed, skip
        logger.info(f"Skipping duplicate event {event_id}")

def handle_event(event):
    # Actual business logic
    pass
Output
Only the first delivery of an event triggers processing; duplicates are silently skipped.
At-Least-Once Semantics
Most message brokers deliver events at least once. Your consumer must be idempotent to avoid data corruption. Treat every event as potentially duplicate.
Production Insight
We once saw a Kafka consumer that processed the same order fulfilment event twice.
Two concurrent consumers both passed a non-atomic SELECT before INSERT check.
Fix: use INSERT ... ON CONFLICT DO NOTHING or SET NX in Redis.
Key Takeaway
Event-driven systems need idempotency too.
Use a dedup key with atomic check-and-store.
TTL must cover the full retry window across all consumers.
Dedup Storage Choice for Event-Driven Systems
IfLow throughput, simple deployment
UseSQL table with unique constraint on event_id.
IfHigh throughput, distributed consumers
UseRedis SET NX with TTL or DynamoDB conditional put.
IfNeed exactly-once semantics
UseStore offset + dedup key in Kafka Connect sink or transactional DB.

Idempotency in Distributed Transactions (Saga Pattern)

When a single operation spans multiple services (e.g., an e-commerce order that charges a customer, deducts inventory, and schedules shipping), you need a saga to coordinate. Each step in a saga can fail, and the saga must compensate (undo) previous steps. Idempotency is critical here because the coordinator may retry a step after a timeout, and compensating actions must also be idempotent to avoid double refunds.

The key idea: each saga step must have its own idempotency key, derived from the saga ID and the step name (e.g., 'saga_123:charge', 'saga_123:refund'). This prevents a retry of the charge step from accidentally being treated as a new charge. A common mistake is using the same idempotency key for a charge and its corresponding refund. This could cause the refund to be skipped if the charge key is still in the dedup store. Always use different keys for forward and compensating actions.

io/thecodeforge/idempotency/SagaCoordinator.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package io.thecodeforge.idempotency;

import java.util.UUID;

public class SagaCoordinator {
    private final PaymentService paymentService;
    private final BookingService bookingService;

    public SagaCoordinator(PaymentService paymentService, BookingService bookingService) {
        this.paymentService = paymentService;
        this.bookingService = bookingService;
    }

    public void bookTrip(String userId, String tripId) {
        String sagaId = UUID.randomUUID().toString();
        String paymentKey = sagaId + ":charge";
        String bookingKey = sagaId + ":book";
        String refundKey = sagaId + ":refund";

        // Step 1: Charge payment
        PaymentResult payment = paymentService.charge(paymentKey, userId, 500.00);
        if (!payment.success()) {
            // Compensate? Nothing to undo yet
            return;
        }
        // Step 2: Book hotel
        BookingResult booking = bookingService.book(bookingKey, tripId);
        if (!booking.success()) {
            // Compensate: refund payment with separate key
            paymentService.refund(refundKey, payment.transactionId());
            return;
        }
        // Success
    }
}
Output
Each saga step uses a unique idempotency key derived from saga ID and step name, ensuring safe retries even during compensating actions.
Forge Tip:
Always generate compensation idempotency keys separately from forward action keys. A duplicate refund could otherwise be mistaken for a duplicate charge.
Production Insight
In a production saga we debugged, the coordinator retried a charge step after a timeout without checking if it had already completed.
The charge succeeded on first attempt, but the coordinator retried with a new key, causing a duplicate charge.
Fix: use the same idempotency key for retries and poll for the result.
Key Takeaway
Distributed transactions need idempotency at every step.
Each saga step must have its own idempotency key.
Compensation actions need separate keys to avoid cross-contamination.
Idempotency in SAGA Steps
IfForward action (e.g., charge payment)
UseUse idempotency key = sagaId + ':forward_label'
IfCompensating action (e.g., refund)
UseUse separate idempotency key = sagaId + ':comp_label'
IfCoordinator retries forward step after timeout
UsePoll with same idempotency key to get result; don't re-execute blindly

Testing Idempotency in Your API

Idempotency is not something you think about once and forget. You need automated tests that verify retry safety under realistic conditions. The key scenarios: same request twice, concurrent requests with same key, request after key expiry, and request with different body for same key.

Write integration tests that send a request with an idempotency key, then send the exact same request again. Assert that the second response matches the first in status, headers, and body. For concurrent tests, use a barrier to send two identical requests simultaneously. This exposes race conditions in the idempotency check.

Also test negative cases: missing idempotency key should return 400, expired key should return 429, mismatched body should return 422. Your tests should cover both the happy path and the failure modes that cause production incidents.

For contract testing, use tools like Pact or Postman collections to enforce that all POST endpoints accept an Idempotency-Key header. This forces the team to implement idempotency from day one.

Don't forget to test the storage layer: simulate a Redis outage and verify that the idempotency check degrades gracefully (e.g., returns 503). A hard failure on storage should not silently accept duplicates.

io/thecodeforge/idempotency/IdempotencyTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package io.thecodeforge.idempotency;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.net.http.*;
import java.net.URI;
import java.util.UUID;

public class IdempotencyTest {

    @Test
    void testIdempotentCreateOrder() throws Exception {
        HttpClient client = HttpClient.newHttpClient();
        String key = UUID.randomUUID().toString();
        String requestBody = "{"customerId":"123","amount":50.0}";
        
        // First request
        HttpRequest first = HttpRequest.newBuilder()
            .uri(URI.create("https://api.example.com/orders"))
            .header("Idempotency-Key", key)
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(requestBody))
            .build();
        HttpResponse<String> firstResponse = client.send(first, HttpResponse.BodyHandlers.ofString());
        
        // Second request with same key
        HttpRequest second = HttpRequest.newBuilder()
            .uri(URI.create("https://api.example.com/orders"))
            .header("Idempotency-Key", key)
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(requestBody))
            .build();
        HttpResponse<String> secondResponse = client.send(second, HttpResponse.BodyHandlers.ofString());
        
        // Assert identical responses
        assertEquals(firstResponse.statusCode(), secondResponse.statusCode());
        assertEquals(firstResponse.body(), secondResponse.body());
    }
}
Output
The test passes only if the server returns the same response for both requests.
Forge Tip:
Include concurrent retry tests in your CI pipeline. Use a thread barrier to send two requests at the exact same time. This catches race conditions that sequential tests miss.
Production Insight
We once had a test that only sent requests sequentially. It passed every time.
In production, concurrent retries caused duplicate orders.
Fix: add a concurrent test using a CountDownLatch to synchronise two threads.
Key Takeaway
Test idempotency with sequential and concurrent retries.
Include negative cases: missing key, expired key, mismatched body.
Automate contract checks to enforce idempotency from the start.
Test Scenarios for Idempotency
IfSame request twice (sequential)
UseAssert identical response (status, headers, body).
IfConcurrent requests with same key
UseUse thread barrier; verify only one creates resource.
IfRequest after key TTL expiry
UseShould return 429 or create new resource if TTL passed.
IfDifferent body with same key
UseShould return 422 Unprocessable Entity.
IfMissing header
UseShould return 400 Bad Request.

Idempotency and Retry Strategies — How Clients Should Use Idempotency Keys

Idempotency is a server-side contract, but the client must follow rules too. The client generates the idempotency key before the first request and reuses it on every retry. The key must be unique per operation — never reuse a key across different operations. If the key is reused, the server may return the wrong cached response.

Retry strategy matters: clients should use exponential backoff with jitter to avoid overwhelming the server. Each retry sends the same idempotency key. If the server responds with 409 Conflict (key already used but first request still processing), the client should retry with the same key after a brief delay. If the server returns 429 Too Many Requests (key expired), the client must generate a new key and resubmit as a fresh operation.

Important: the client must not change the request body between retries. If the body changes, the idempotency key is invalid for the new request — the server should return 422. The client should always send the exact same payload on retries.

For mobile apps, store the key and request payload locally until a definitive success or irrecoverable failure is received. This prevents duplicate charges even if the app is killed and restarted.

io/thecodeforge/idempotency/ClientRetry.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import requests
import uuid
import time

def place_order_with_retry(payload: dict, max_retries: int = 3):
    idempotency_key = str(uuid.uuid4())
    headers = {'Idempotency-Key': idempotency_key}
    for attempt in range(max_retries):
        try:
            response = requests.post('https://api.example.com/orders', json=payload, headers=headers)
            if response.status_code == 200:
                return response.json()
            elif response.status_code in (409, 429):
                # 409: still processing, retry with same key
                time.sleep(2 ** attempt)
                continue
            else:
                response.raise_for_status()
        except requests.exceptions.RequestException as e:
            if attempt == max_retries - 1:
                raise
            time.sleep(2 ** attempt)
    raise Exception('Max retries exceeded')
Output
Retries use the same idempotency key; server deduplicates automatically.
Client-Side Rule
Never regenerate the idempotency key between retries. The key is what links the retry to the original request.
Production Insight
A common client bug: regenerating the key on every retry.
This bypasses the server's dedup logic and causes duplicates.
Rule: the key is generated once per operation, not per attempt.
Key Takeaway
Idempotency is a contract between client and server.
The client generates the key once and reuses it.
Never change the payload between retries.
Client Retry Decisions
IfHTTP 200 OK
UseSuccess. Store key and response for audit.
IfHTTP 409 Conflict
UseAnother request with same key is in flight. Retry with same key after short delay.
IfHTTP 429 Too Many Requests / Timeout
UseKey may have expired. Generate new key and resubmit as new operation.
IfHTTP 422 Unprocessable Entity
UseBody mismatch. Do not retry; fix payload.
● Production incidentPOST-MORTEMseverity: high

Double Charge from Payment Gateway Retry

Symptom
Customer sees two identical payment transactions in account history.
Assumption
The payment gateway's API would reject duplicate requests automatically.
Root cause
Payment API was a simple POST with no idempotency key. Gateway processed each retry as a new order.
Fix
Add Idempotency-Key header; store processed keys in Redis with TTL; return cached response on duplicate.
Key lesson
  • Idempotency is not optional for any operation that creates side effects.
  • Always assume clients will retry.
  • Store the full response, not just a processed flag.
  • Define a TTL that exceeds the longest expected retry window (at least 24 hours).
Production debug guideSymptom → Action guide for common idempotency failures6 entries
Symptom · 01
Duplicate order created despite idempotency key
Fix
Check if idempotency key is being sent correctly; verify key uniqueness across requests; inspect storage for key expiry.
Symptom · 02
Idempotency key collision (different requests with same key)
Fix
Ensure client generates unique keys (UUID v4); audit key generation logic; add prefix per client or operation type.
Symptom · 03
Idempotency check returns wrong cached response
Fix
Verify that response is fully serialized and identical; check for race conditions in the persistence layer.
Symptom · 04
Duplicate transaction even with idempotency key
Fix
Check if the first request failed before processing completed; ensure idempotency check is atomic (use DB locks or Redis WATCH).
Symptom · 05
Client did not send Idempotency-Key header
Fix
Enforce header requirement at API gateway; return 400 Bad Request with clear message; log missing keys for audit.
Symptom · 06
Response body differs on retry
Fix
Ensure the stored response includes full headers and body; confirm serialization includes all fields; check for state changes after first processing.
★ Quick Idempotency Debug Cheat SheetFive-minute drill for diagnosing idempotency failures in production
Duplicate transaction seen
Immediate action
Check request logs for Idempotency-Key header
Commands
grep 'Idempotency-Key' /var/log/app/api.log
redis-cli --raw GET "idempotency:${KEY}"
Fix now
Add idempotency key validation and store with EXPIRE 86400
Cached response mismatch+
Immediate action
Compare stored response body with original response
Commands
curl -H 'Idempotency-Key: X' -X POST -d '...' https://api.example.com/orders
grep 'response_body' /var/log/app/idempotency.log
Fix now
Serialize response fully (including headers) before caching
Race condition on first request+
Immediate action
Check if two requests with same key arrived simultaneously
Commands
grep 'before_idempotency_check' /var/log/app/api.log | tail -100
redis-cli --eval /tmp/atomic_check.lua
Fix now
Use Redis SET NX with expiry to guarantee atomic first-write
Key expired before retry completed+
Immediate action
Check TTL configuration vs. retry timeout in client
Commands
redis-cli TTL "idempotency:${KEY}"
grep 'retry_timeout' /etc/client-config.yaml
Fix now
Increase TTL to at least 24 hours and add jitter to retry intervals
Response body differs on retry+
Immediate action
Check if the stored response includes full headers and body
Commands
redis-cli GET "idempotency:${KEY}" | jq .
curl -v -H 'Idempotency-Key: X' -X POST ...
Fix now
Store entire HTTP response (status, headers, body) atomically
Idempotency Key Storage Options
StorageAtomicityTTL SupportPerformanceBest Use Case
In-Memory (ConcurrentHashMap)putIfAbsent is atomicNot built-in~0.01ms per opSingle-instance, low throughput
Redis (SET NX)SET NX is atomicEXPIRE supported~1ms per opMulti-instance, high throughput
SQL (unique constraint)INSERT ON CONFLICT atomicManual cleanup needed~5ms per opACID required, moderate throughput
DynamoDB (conditional put)ConditionalPut is atomicTTL attribute supported~10ms per opDistributed across regions

Key takeaways

1
Idempotency ensures retries don't create duplicates
essential for reliable distributed systems.
2
HTTP method is a hint, not a guarantee; always verify side effects under retry.
3
Use Idempotency-Key header for non-idempotent operations like POST.
4
Atomic check-and-set (Redis SET NX, SQL ON CONFLICT) prevents race conditions.
5
Store the full response, not just a flag, and always include method+path in the lookup.
6
TTL must exceed the maximum retry window
24 hours for most APIs, 7 days for payments.

Common mistakes to avoid

5 patterns
×

Using idempotency key without storing the response body

Symptom
On retry, the server returns an empty or inconsistent response because only a flag was stored, not the full representation.
Fix
Store the entire HTTP response (status code, headers, body) atomically with the idempotency key.
×

Reusing the same idempotency key for different operations

Symptom
A payment retry gets the cached response from a refund, or vice versa, causing confusing errors.
Fix
Always composite the lookup key with request method and path. Never allow the same key across different endpoints.
×

Setting TTL too short (e.g., 60 seconds)

Symptom
Retries that arrive after the TTL expiry create duplicate transactions because the key is treated as new.
Fix
Set TTL to at least 24 hours. For payment endpoints, use 7 days. Add a grace period for audit logging.
×

Missing atomic check-and-set for concurrent requests

Symptom
Two identical requests arrive simultaneously; both pass the existence check and both execute the operation, causing duplicates.
Fix
Use Redis SET NX, SQL INSERT ON CONFLICT, or a database unique constraint to ensure only one request proceeds.
×

Client regenerating the idempotency key on each retry

Symptom
The server sees each retry as a new request and processes it separately, defeating deduplication.
Fix
Generate the key once before the first request. Reuse it on every retry until a definitive success or failure.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain idempotency in REST APIs. Which HTTP methods are idempotent and ...
Q02SENIOR
How would you implement idempotency for a payment API that uses POST?
Q03SENIOR
What is a race condition in idempotency enforcement? How do you prevent ...
Q04SENIOR
How does idempotency apply to event-driven architectures?
Q05SENIOR
How would you handle idempotency in a saga pattern with compensating tra...
Q01 of 05SENIOR

Explain idempotency in REST APIs. Which HTTP methods are idempotent and why?

ANSWER
Idempotency means that multiple identical requests produce the same server state as a single request. GET, HEAD, PUT, DELETE, OPTIONS, and TRACE are idempotent by HTTP specification because their intended side effects are deterministic. POST and PATCH are not idempotent by default — POST creates resources, and PATCH can be incremental. However, a PUT that replaces a resource is idempotent, while a PUT that increments a counter is not. Always verify actual side effects, not just method names.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What is the difference between idempotency and safety in HTTP methods?
02
Can a non-idempotent operation be made idempotent without an idempotency key?
03
What happens if two different clients send the same idempotency key?
04
Should I store idempotency keys permanently?
🔥

That's Fundamentals. Mark it forged?

11 min read · try the examples if you haven't

Previous
SLA and Uptime Calculation
10 / 10 · Fundamentals
Next
Load Balancing