Metadata
| Field | Value |
|---|---|
| Slug | idempotency |
| Difficulty | Intermediate |
| Estimated Reading Time | 12 min |
| Estimated Coding Time | 20 min |
| Tier | 4 — Reliability & Fault Tolerance |
| Implementation Language | Python |
| SEO Description | Learn what Idempotency is in system design. Understand how to prevent duplicate payments, use Idempotency Keys, and build a safe, retryable API in Python. |
1. Overview
What problem does it solve?
In distributed systems, networks are flaky.
Imagine a user clicks "Checkout" on an e-commerce site. The app sends a POST /charge request to the backend. The backend successfully charges the user's credit card for $100. But right as the backend sends the 200 OK response, the Wi-Fi drops.
The user's app shows an error: "Network timeout."
The user clicks "Checkout" again.
The backend receives another POST /charge and charges another $100. The user was double-charged.
An operation is Idempotent if applying it multiple times has the exact same effect as applying it once.
What breaks without it?
- Double Charges: The classic payment processing nightmare.
- Duplicate Data: Submitting a form twice creates two identical users in the database.
- Retry Storms: If you implement Retry & Backoff on an API that is not idempotent, you will systematically corrupt your database every time the network lags.
2. Motivation
In mathematics, an idempotent operation is one where f(f(x)) = f(x). For example, abs() is idempotent because abs(abs(-5)) is the same as abs(-5). But x + 1 is not.
In HTTP, certain methods are defined as naturally idempotent by the specification:
GET,PUT,DELETEare idempotent. (Deleting a user twice still results in the user being deleted).POSTis NOT idempotent. (Posting a user twice creates two users).
As systems moved to Microservices and asynchronous Message Queues, the guarantee of "Exactly-Once Delivery" became nearly impossible to achieve at the network layer. The industry shifted to "At-Least-Once Delivery" combined with Idempotent Consumers. (Stripe popularized the Idempotency-Key HTTP header for APIs).
3. Real-World Usage
| System | Use Case |
|---|---|
| Stripe API | Requires an Idempotency-Key header on all POST requests to ensure no one is double-charged during network retries. |
| Message Queue Consumers | Workers processing messages from Kafka/SQS must be idempotent in case the queue resends a message. |
| Infrastructure as Code (Terraform/Ansible) | Running terraform apply 10 times results in the exact same infrastructure state as running it once. |
4. Prerequisites
| Concept | Block |
|---|---|
| HTTP Methods | 002 REST API Design |
| Message Retries | 024 Message Queues |
5. Visual Explanation
The Problem: The Two Generals' Problem
When a network times out, the client cannot know if the request failed before reaching the server, or if the response failed after the server processed it.
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#ffcc00', 'edgeLabelBackground':'#ffffff'}}}%%
sequenceDiagram
participant Client
participant Server
Client->>Server: POST /charge ($100)
Note over Server: Charges Credit Card
Server--xClient: 200 OK (Packet Lost in Transit)
Note over Client: Timeout! Did it work?<br/>I have to retry!
Client->>Server: POST /charge ($100)
Note over Server: Charges Credit Card AGAIN
Server-->>Client: 200 OK
The Solution: The Idempotency Key
sequenceDiagram
participant Client
participant Server
participant Cache as Redis (Idempotency Store)
Note over Client: Generate UUID: abc-123
Client->>Server: POST /charge (Key: abc-123)
Server->>Cache: Exists('abc-123')? -> NO
Note over Server: Charges Credit Card
Server->>Cache: Save('abc-123', Response: 200 OK)
Server--xClient: 200 OK (Packet Lost!)
Note over Client: Timeout! Retrying exact same request.
Client->>Server: POST /charge (Key: abc-123)
Server->>Cache: Exists('abc-123')? -> YES!
Note over Server: Skip charging. Fetch old response.
Server-->>Client: 200 OK (Cached Response)
6. Internal Working
The Idempotency Key
The client generates a unique identifier (usually a UUID v4) for the specific operation it wants to perform. This is passed in the header: Idempotency-Key: <uuid>.
- Check: Server receives request. Checks if the key exists in its Idempotency Store (e.g., Redis or a Postgres table).
- Hit: If the key exists, the server immediately returns the cached HTTP response from the previous successful attempt. No business logic is run.
- Miss: If the key does not exist, the server inserts the key with a status of
IN_PROGRESS. - Execute: The server runs the business logic (e.g., charges the card).
- Save: The server updates the key's value in the store with the final HTTP response payload and status code.
- Return: The server returns the response to the client.
Concurrent Retries
What if the client clicks the button twice very fast, sending two requests with the same key simultaneously?
Because of step 3 (IN_PROGRESS), the second request will see that the key is currently being processed. It should either block and wait, or instantly return a 409 Conflict (Client should retry).
7. Implementation
Why Python? Python allows us to mock a web server and a Redis cache easily, clearly demonstrating the storage and retrieval of the idempotency token.
"""
030 - Idempotency
A simulation of a Payment API that prevents double-charges
using an Idempotency Key stored in a mock Redis cache.
"""
import uuid
import time
from enum import Enum
class Status(Enum):
IN_PROGRESS = "IN_PROGRESS"
COMPLETED = "COMPLETED"
# Mock Database & Cache
bank_balances = {"user_1": 500} # User starts with $500
idempotency_store = {} # Mocks Redis: Key -> (Status, Response)
def charge_credit_card(user_id, amount):
"""The core business logic that we NEVER want to run twice."""
print(f" [Stripe] Charging {user_id} ${amount}...")
time.sleep(1) # Simulate network call to payment processor
bank_balances[user_id] -= amount
return {"status": "success", "transaction_id": "txn_888", "amount": amount}
def api_post_charge(user_id, amount, idempotency_key=None):
"""The API Endpoint Handler."""
if not idempotency_key:
return 400, {"error": "Idempotency-Key header is required"}
# 1. Check the Idempotency Store
if idempotency_key in idempotency_store:
status, cached_response = idempotency_store[idempotency_key]
if status == Status.IN_PROGRESS:
print(f"[API] ⚠️ Concurrent request detected for {idempotency_key}")
return 409, {"error": "Request already in progress. Please wait."}
if status == Status.COMPLETED:
print(f"[API] ♻️ Idempotency Hit! Returning cached response.")
return 200, cached_response
# 2. Mark as IN_PROGRESS (Atomic lock in real life)
idempotency_store[idempotency_key] = (Status.IN_PROGRESS, None)
try:
# 3. Execute fragile business logic
result = charge_credit_card(user_id, amount)
# 4. Save result and mark COMPLETED
idempotency_store[idempotency_key] = (Status.COMPLETED, result)
print("[API] ✅ Payment successful. Result saved.")
return 200, result
except Exception as e:
# On actual error, remove the key so they can try again
del idempotency_store[idempotency_key]
return 500, {"error": "Internal Server Error"}
# ── Test Harness ──
if __name__ == "__main__":
user = "user_1"
amount = 100
print(f"Initial Balance: ${bank_balances[user]}\n")
# Client generates a unique key for THIS specific checkout attempt
idem_key = str(uuid.uuid4())
print("--- 1. Client sends initial request ---")
status, resp = api_post_charge(user, amount, idempotency_key=idem_key)
print(f"Response: {status} {resp}")
print(f"Current Balance: ${bank_balances[user]}\n")
print("--- 2. Network drops! Client retries with EXACT SAME KEY ---")
# Because it's a retry of the same action, the client uses the same key
status, resp = api_post_charge(user, amount, idempotency_key=idem_key)
print(f"Response: {status} {resp}")
print(f"Current Balance: ${bank_balances[user]}\n")
print("--- 3. Client buys a second item (Generates NEW KEY) ---")
# It's a new checkout, so the client generates a new key
new_key = str(uuid.uuid4())
status, resp = api_post_charge(user, amount, idempotency_key=new_key)
print(f"Response: {status} {resp}")
print(f"Current Balance: ${bank_balances[user]}\n")
Sample Output
Initial Balance: $500
--- 1. Client sends initial request ---
[Stripe] Charging user_1 $100...
[API] ✅ Payment successful. Result saved.
Response: 200 {'status': 'success', 'transaction_id': 'txn_888', 'amount': 100}
Current Balance: $400
--- 2. Network drops! Client retries with EXACT SAME KEY ---
[API] ♻️ Idempotency Hit! Returning cached response.
Response: 200 {'status': 'success', 'transaction_id': 'txn_888', 'amount': 100}
Current Balance: $400
--- 3. Client buys a second item (Generates NEW KEY) ---
[Stripe] Charging user_1 $100...
[API] ✅ Payment successful. Result saved.
Response: 200 {'status': 'success', 'transaction_id': 'txn_888', 'amount': 100}
Current Balance: $300
Notice in Step 2, the [Stripe] log doesn't print, and the balance doesn't drop. The cache caught it!
8. Complexity
| Metric | Details |
|---|---|
| Time Complexity | O(1) for Redis cache lookup. Adds ~1-2ms to the API response time. |
| Space Complexity | O(N) where N is the number of requests kept in the cache. To prevent OOM, keys usually have a TTL (Time To Live) of 24 hours. |
| Database Contention | If using SQL for idempotency, it requires an extra INSERT and UPDATE per request, which can slow down high-throughput systems. |
9. Trade-offs
| Strategy | Pros | Cons |
|---|---|---|
| Natural Idempotency | Free. (e.g., UPDATE users SET status='active'). You can run it 100 times safely. | Impossible for actions that create resources or mutate state relative to current state (e.g., balance = balance - 100). |
| Idempotency Keys | Safest. Solves the Two Generals problem on flaky networks. | Requires client cooperation (they must generate and store the UUID). Requires server infrastructure (Redis). |
10. Production Evolution
| Feature | This Implementation | Production |
|---|---|---|
| Atomicity | Python Dictionary | Checking IN_PROGRESS and setting it must be an atomic operation (e.g., Redis SETNX or Postgres INSERT ... ON CONFLICT). |
| TTL (Expiration) | Infinite | Keys expire after 24 hours to save memory. A retry after 24 hours is treated as a new request. |
| Payload Hashing | Trusts the key | The server hashes the request body and stores it with the key. If the client sends the same key but a different body (e.g., amount = $200), the server throws a 400 Bad Request to prevent misuse. |
11. Common Bugs
| Bug | What happens | Fix |
|---|---|---|
| Client changes the key on retry | Mobile app retries the request but generates a new UUID on every loop. User gets charged 5 times. | The client must store the generated UUID locally and reuse it for all retries of that specific user action. |
| Race Conditions | Two requests with the same key arrive exactly at the same millisecond. Both pass the if key in store: check. Both charge the card. | Use Atomic Locks (e.g. Database Unique Constraints or Redis SETNX) for the insertion step. |
| Idempotency on GET | Adding an idempotency key to a GET request to cache it. | Don't reinvent the wheel. Use standard HTTP Cache-Control headers or a CDN for GET requests. Idempotency Keys are for mutations (POST/PATCH). |
12. Interview Questions
-
What is an idempotent operation? Hint: An operation that produces the same result if executed once or multiple times.
-
Why is an Idempotency Key required for payment APIs? Hint: The Two Generals problem. If the network drops, the client doesn't know if the server charged them. They must retry. The key tells the server "I am retrying checkout attempt A, do not charge them twice."
-
How do you prevent a race condition where a user double-clicks the submit button rapidly? Hint: The idempotency store must use atomic locks (like Redis SETNX or SQL unique constraints). The second request hits the lock and returns a 409 Conflict.
-
Is
PUTidempotent? IsPOSTidempotent? Hint:PUT(replace whole resource) is idempotent by definition.POST(create new resource) is not.
13. Used By (Downstream Blocks)
- 029 Retry & Exponential Backoff — You can only safely enable automatic retries if the downstream API is idempotent.
- 024 Message Queues — Pub/Sub guarantees at-least-once delivery. The consumer must be idempotent to handle duplicates.
14. Used In (Case Studies)
| System | Use Case |
|---|---|
| Stripe | Requires Idempotency-Key headers for all charges and refunds. |
| Uber | Driver payout processing uses strict idempotency to ensure drivers aren't paid twice for the same ride. |
| Message IDs act as idempotency keys. If the app resends a message, the server recognizes the ID and doesn't duplicate the text in the chat history. |
15. Related Blocks
| Relationship | Block |
|---|---|
| Previous | 024 Message Queues & Pub/Sub |
| Parallel | 029 Retry & Exponential Backoff |
| Next | 026 Distributed Transactions |
16. Try It Yourself
Exercise 1: Payload Validation
Modify the api_post_charge function to store the amount alongside the response in the cache. If a request comes in with a matching key, but a different amount, throw an error to prevent malicious behavior.
Exercise 2: Database Constraints
Imagine you don't have Redis. Design a SQL table schema for a payments table that natively guarantees idempotency using a UNIQUE constraint, removing the need for a separate idempotency store entirely.
Website Metadata
| Field | Value |
|---|---|
| Hero Title | Idempotency |
| Hero Subtitle | How to safely retry failed operations, prevent double charges, and build robust APIs. |
| Breadcrumb | System Design → Building Blocks → Idempotency |
| Sidebar Category | Tier 4 — Reliability |
| Search Keywords | idempotency, idempotent api, idempotency key, retry, payment api, double charge, stripe api, microservices |
| Internal Links | ← 024 Message Queues · → 029 Retry & Backoff |
| Suggested Illustration | A user furiously clicking a "Buy" button multiple times, but a shield in front of the database only lets the very first click through, deflecting the rest. |
| Suggested Animation | A request with a key enters the server. The server processes it. A second identical request enters. The server recognizes the key, skips processing, and instantly returns the cached receipt. |