Loading content...
A customer clicks "Pay Now" to purchase a $500 item. The request reaches your payment service, which successfully charges their credit card. But before the response reaches the customer's browser, the network connection drops.
The customer sees an error: "Something went wrong. Please try again."
They click "Pay Now" again. Your system processes another $500 charge.
The customer is now charged $1,000. They call support, furious. Your company issues a refund, spends an hour on support, and loses the customer's trust forever.
This is the duplicate execution problem. It occurs whenever:
Idempotency solves this problem by ensuring that executing an operation multiple times produces the same result as executing it once.
By the end of this page, you will deeply understand idempotency as a mathematical and practical concept, how to design idempotent APIs, implement idempotency keys, handle the edge cases and race conditions that arise, and apply these patterns to build retry-safe distributed systems.
Definition: An operation is idempotent if applying it multiple times has the same effect as applying it once.
Mathematically: f(f(x)) = f(x)
Or more generally: f(x) = f(f(x)) = f(f(f(x))) = ... = f^n(x) for any n ≥ 1
In distributed systems terms: If a client sends the same request N times, the resulting system state should be identical to sending it once.
| Operation | Idempotent? | Why? | Safe to Retry? |
|---|---|---|---|
| Set user email to 'new@email.com' | ✅ Yes | Same value set regardless of repetition | Yes |
| Delete user with ID 42 | ✅ Yes | User is gone whether deleted once or many times | Yes |
| Get user profile | ✅ Yes | Reading doesn't change state | Yes (always) |
| Create new order | ❌ No | Each execution creates another order | No |
| Increment counter by 1 | ❌ No | Each execution adds 1 more | No |
| Append 'log entry' to list | ❌ No | Each execution adds another entry | No |
| Charge credit card $100 | ❌ No | Each execution charges again | No |
The key insight: Idempotency is about results, not just responses. Even if the response differs (e.g., first POST returns 201 Created, retry returns 200 OK), the operation is idempotent if the system state is the same.
Idempotency is distinct from safety. A safe operation has no side effects (like GET). An idempotent operation may have side effects, but repeating it doesn't multiply them. PUT and DELETE are idempotent but not safe—they modify state, just predictably.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
/** * Examples: Idempotent vs Non-Idempotent Operations */ // =========================================// IDEMPOTENT: Set operations// =========================================class UserProfile { email: string = ""; name: string = "";} function setUserEmail(user: UserProfile, newEmail: string): void { // No matter how many times we call this with the same email, // the result is the same user.email = newEmail;} const user = new UserProfile();setUserEmail(user, "alice@example.com"); // email = "alice@example.com"setUserEmail(user, "alice@example.com"); // still "alice@example.com"setUserEmail(user, "alice@example.com"); // still "alice@example.com"// IDEMPOTENT ✅ // =========================================// IDEMPOTENT: Delete operations (by design)// =========================================const users = new Map<string, UserProfile>();users.set("user-42", new UserProfile()); function deleteUser(userId: string): boolean { // Returns true if deleted, false if didn't exist // Either way, after execution, user doesn't exist return users.delete(userId);} deleteUser("user-42"); // Returns true, user is gonedeleteUser("user-42"); // Returns false, user still gonedeleteUser("user-42"); // Returns false, user still gone// Final state is the same regardless of call count ✅ // =========================================// NON-IDEMPOTENT: Increment operations// =========================================let counter = 0; function incrementCounter(): number { return ++counter;} incrementCounter(); // counter = 1incrementCounter(); // counter = 2incrementCounter(); // counter = 3// Each execution changes the result - NOT IDEMPOTENT ❌ // =========================================// NON-IDEMPOTENT: Create operations// =========================================const orders: Array<{ id: string; amount: number }> = []; function createOrder(amount: number): string { const id = `order-${Math.random().toString(36).slice(2)}`; orders.push({ id, amount }); return id;} createOrder(500); // Creates order 1createOrder(500); // Creates order 2 (different!)createOrder(500); // Creates order 3 (different!)// Each execution creates new state - NOT IDEMPOTENT ❌ // =========================================// MAKING IT IDEMPOTENT: Idempotency key// =========================================const processedOrders = new Map<string, string>(); // idempKey -> orderId function createOrderIdempotent(idempotencyKey: string, amount: number): string { // Check if we've already processed this key const existingOrderId = processedOrders.get(idempotencyKey); if (existingOrderId) { // Already processed - return same result return existingOrderId; } // First time - create the order const id = `order-${Math.random().toString(36).slice(2)}`; orders.push({ id, amount }); // Remember that we processed this key processedOrders.set(idempotencyKey, id); return id;} const key = "order-req-abc123";createOrderIdempotent(key, 500); // Creates order, returns "order-xyz"createOrderIdempotent(key, 500); // Returns same "order-xyz", no new ordercreateOrderIdempotent(key, 500); // Returns same "order-xyz", no new order// NOW IT'S IDEMPOTENT ✅An idempotency key is a unique identifier attached to a request that allows the server to recognize and deduplicate repeated requests. It's the industry-standard solution for making non-idempotent operations safely retryable.
How idempotency keys work:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
/** * Idempotency Key Protocol * * Demonstrates the full lifecycle of idempotency key handling. */ interface IdempotencyRecord { key: string; status: "processing" | "completed" | "failed"; response?: unknown; error?: string; createdAt: Date; expiresAt: Date;} interface RequestContext { idempotencyKey?: string; method: string; path: string; body: unknown;} class IdempotencyKeyStore { private records = new Map<string, IdempotencyRecord>(); private readonly defaultTtlMs: number = 24 * 60 * 60 * 1000; // 24 hours /** * Check if a request with this key has been processed. * Returns the record if exists, null otherwise. */ get(key: string): IdempotencyRecord | null { const record = this.records.get(key); if (!record) return null; // Check expiration if (record.expiresAt < new Date()) { this.records.delete(key); return null; } return record; } /** * Start processing a new request. Creates a "processing" record. * Returns false if key already exists (duplicate request). */ startProcessing(key: string): boolean { if (this.get(key)) { return false; // Already exists } const now = new Date(); this.records.set(key, { key, status: "processing", createdAt: now, expiresAt: new Date(now.getTime() + this.defaultTtlMs), }); return true; } /** * Complete processing with a successful response. */ complete(key: string, response: unknown): void { const record = this.records.get(key); if (record) { record.status = "completed"; record.response = response; } } /** * Mark processing as failed. */ fail(key: string, error: string): void { const record = this.records.get(key); if (record) { record.status = "failed"; record.error = error; } } /** * Remove a processing record (for cleanup after transient failures * that should allow retry). */ remove(key: string): void { this.records.delete(key); }} /** * Idempotent Request Handler */class IdempotentHandler { private store = new IdempotencyKeyStore(); async handleRequest<T>( context: RequestContext, processor: () => Promise<T> ): Promise<{ status: number; body: T | { error: string } }> { const key = context.idempotencyKey; // If no idempotency key provided, process normally (non-idempotent) if (!key) { const result = await processor(); return { status: 200, body: result }; } // Check for existing record const existing = this.store.get(key); if (existing) { switch (existing.status) { case "completed": // Return cached response console.log(`Idempotency hit: returning cached result for key ${key}`); return { status: 200, body: existing.response as T }; case "processing": // Request is still being processed (concurrent duplicate) return { status: 409, body: { error: "Request is still processing" } as any }; case "failed": // Previous attempt failed - decide based on error type // For transient failures, allow retry // For permanent failures, return cached error return { status: 422, body: { error: existing.error || "Previous request failed" } as any }; } } // New request - start processing if (!this.store.startProcessing(key)) { // Race condition: another thread started processing return { status: 409, body: { error: "Duplicate request in progress" } as any }; } try { // Process the actual request const result = await processor(); // Mark as completed and cache result this.store.complete(key, result); return { status: 200, body: result }; } catch (error) { // Decide whether to cache the failure if (isTransientError(error)) { // Transient error - remove record to allow retry this.store.remove(key); } else { // Permanent error - cache the failure this.store.fail(key, (error as Error).message); } throw error; } }} function isTransientError(error: unknown): boolean { const message = (error as Error).message?.toLowerCase() || ""; return message.includes("timeout") || message.includes("connection") || message.includes("temporarily");} // =========================================// Usage Example: Payment API// ========================================= interface PaymentRequest { amount: number; currency: string; customerId: string;} interface PaymentResult { paymentId: string; status: string; amount: number;} const handler = new IdempotentHandler(); async function processPayment( request: PaymentRequest, idempotencyKey?: string): Promise<PaymentResult> { return handler.handleRequest( { idempotencyKey, method: "POST", path: "/payments", body: request, }, async () => { // Actual payment processing logic console.log(`Processing payment of ${request.amount} ${request.currency}`); // Simulate external payment gateway call await new Promise(r => setTimeout(r, 100)); return { paymentId: `pay_${Math.random().toString(36).slice(2)}`, status: "succeeded", amount: request.amount, }; } ) as Promise<PaymentResult>;}Idempotency-Key rather than the request body.Implementing idempotency correctly requires handling several subtle edge cases. Let's explore the patterns and common pitfalls.
Pattern 1: Database-Backed Idempotency
Store idempotency records in the same database as your business data, using transactions to ensure atomicity.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
/** * Database-Backed Idempotency Implementation * * Uses ACID transactions to ensure atomic idempotency checks * and business logic execution. */ interface PrismaClient { $transaction<T>(fn: (tx: any) => Promise<T>): Promise<T>; idempotencyKey: IdempotencyKeyModel; payment: PaymentModel;} interface IdempotencyKeyModel { findUnique(args: { where: { key: string } }): Promise<IdempotencyRecord | null>; create(args: { data: Partial<IdempotencyRecord> }): Promise<IdempotencyRecord>; update(args: { where: { key: string }; data: Partial<IdempotencyRecord> }): Promise<void>;} interface PaymentModel { create(args: { data: any }): Promise<any>;} interface IdempotencyRecord { key: string; status: string; response: string | null; requestHash: string; createdAt: Date;} async function processPaymentIdempotently( prisma: PrismaClient, idempotencyKey: string, request: PaymentRequest): Promise<PaymentResult> { const requestHash = hashRequest(request); // Use a transaction to ensure atomicity return prisma.$transaction(async (tx) => { // Step 1: Check for existing record const existing = await tx.idempotencyKey.findUnique({ where: { key: idempotencyKey }, }); if (existing) { // Verify request matches (prevent key reuse with different data) if (existing.requestHash !== requestHash) { throw new Error("Idempotency key already used with different request"); } if (existing.status === "completed" && existing.response) { // Return cached result return JSON.parse(existing.response) as PaymentResult; } if (existing.status === "processing") { throw new Error("Request already in progress"); } // If failed, allow retry by continuing } // Step 2: Create or update record as "processing" if (!existing) { await tx.idempotencyKey.create({ data: { key: idempotencyKey, status: "processing", requestHash, createdAt: new Date(), }, }); } else { await tx.idempotencyKey.update({ where: { key: idempotencyKey }, data: { status: "processing" }, }); } // Step 3: Execute business logic (still within transaction) const payment = await tx.payment.create({ data: { amount: request.amount, currency: request.currency, customerId: request.customerId, status: "pending", }, }); // Step 4: (Outside transaction) Call external payment gateway // Note: External calls should be outside the DB transaction! const result: PaymentResult = { paymentId: payment.id, status: "succeeded", amount: request.amount, }; // Step 5: Update idempotency record with result await tx.idempotencyKey.update({ where: { key: idempotencyKey }, data: { status: "completed", response: JSON.stringify(result), }, }); return result; });} function hashRequest(request: PaymentRequest): string { const crypto = require("crypto"); return crypto .createHash("sha256") .update(JSON.stringify(request)) .digest("hex");}Never hold a database transaction open while making external API calls (like payment gateways). The external call might take seconds or timeout, causing transaction lock contention. Instead: create the idempotency record, commit, make the external call, then update the record in a new transaction.
Pattern 2: Two-Phase Idempotency
For operations involving external services, use a two-phase approach to handle the uncertainty of external call outcomes.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
/** * Two-Phase Idempotency for External Services * * Phase 1: Record intent and get lock * Phase 2: Execute external call and update status * * This handles the case where the external call succeeds but * we fail to record the result (crash between call and update). */ interface PaymentGateway { charge(amount: number, paymentMethod: string): Promise<{ chargeId: string }>; getCharge(chargeId: string): Promise<{ status: string } | null>;} interface IdempotencyStore { get(key: string): Promise<IdempotencyRecord | null>; setProcessing(key: string, requestHash: string): Promise<boolean>; setCompleted(key: string, response: unknown): Promise<void>; setFailed(key: string, error: string): Promise<void>; setExternalId(key: string, externalId: string): Promise<void>; delete(key: string): Promise<void>;} class TwoPhaseIdempotentPayment { constructor( private store: IdempotencyStore, private gateway: PaymentGateway ) {} async processPayment( idempotencyKey: string, request: PaymentRequest ): Promise<PaymentResult> { const requestHash = hashRequest(request); // ======================== // Step 1: Check existing state // ======================== const existing = await this.store.get(idempotencyKey); if (existing) { // Handle based on status switch (existing.status) { case "completed": // Return cached result return existing.response as PaymentResult; case "processing": // Previous attempt might be stuck - check if we need recovery return this.recoverOrWait(idempotencyKey, existing, request); case "failed": // Previous failure - check if retryable if (this.isRetryableFailure(existing.error || "")) { // Allow retry - continue processing break; } throw new Error(existing.error || "Previous request failed"); } } // ======================== // Step 2: Acquire idempotency lock // ======================== const acquired = await this.store.setProcessing(idempotencyKey, requestHash); if (!acquired) { throw new Error("Request already in progress"); } try { // ======================== // Step 3: Make external call // ======================== const charge = await this.gateway.charge( request.amount, request.customerId ); // Record the external ID immediately (partial state) await this.store.setExternalId(idempotencyKey, charge.chargeId); // ======================== // Step 4: Build and cache result // ======================== const result: PaymentResult = { paymentId: charge.chargeId, status: "succeeded", amount: request.amount, }; await this.store.setCompleted(idempotencyKey, result); return result; } catch (error) { // Handle different failure modes if (this.isTransientError(error)) { // Transient error: allow retry await this.store.delete(idempotencyKey); } else { // Permanent error: cache failure await this.store.setFailed(idempotencyKey, (error as Error).message); } throw error; } } /** * Recovery logic for when we find a "processing" record. * The previous request might have succeeded externally but * crashed before updating our record. */ private async recoverOrWait( key: string, record: IdempotencyRecord, request: PaymentRequest ): Promise<PaymentResult> { // If we have an external ID, check with the payment gateway const externalId = (record as any).externalId; if (externalId) { const externalStatus = await this.gateway.getCharge(externalId); if (externalStatus) { // External call did succeed! Recover by caching the result. const result: PaymentResult = { paymentId: externalId, status: externalStatus.status, amount: request.amount, }; await this.store.setCompleted(key, result); return result; } } // No external ID or external call not found // Either still processing or failed before external call // Check age of record const ageMs = Date.now() - record.createdAt.getTime(); if (ageMs > 60000) { // Older than 1 minute // Likely crashed - reset and allow retry await this.store.delete(key); throw new Error("Previous request timed out, please retry"); } throw new Error("Request is still processing"); } private isTransientError(error: unknown): boolean { const message = (error as Error).message?.toLowerCase() || ""; return message.includes("timeout") || message.includes("temporarily") || message.includes("overloaded"); } private isRetryableFailure(error: string): boolean { return error.toLowerCase().includes("timeout"); }}Idempotency implementation is riddled with race conditions and edge cases. Understanding these is critical for correct implementation.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
/** * Solutions to Common Race Conditions */ // =========================================// Solution 1: Atomic check-and-set with unique constraints// ========================================= // In PostgreSQL, use INSERT ... ON CONFLICT// In Redis, use SET NX (set if not exists)// In DynamoDB, use conditional writes async function atomicCheckAndSet( redis: RedisClient, key: string, ttlSeconds: number): Promise<"acquired" | "exists"> { // SET key value NX EX ttl // NX = only set if not exists // Returns "OK" if set, null if already exists const result = await redis.set(key, "processing", "NX", "EX", ttlSeconds); return result === "OK" ? "acquired" : "exists";} // Usage in handlerasync function handleWithAtomicLock<T>( redis: RedisClient, key: string, processor: () => Promise<T>): Promise<T> { const lockStatus = await atomicCheckAndSet(redis, key, 3600); if (lockStatus === "exists") { // Key already exists - check its status const existing = await redis.get(key); if (existing && existing.startsWith("{")) { // It's a completed result return JSON.parse(existing); } throw new Error("Request in progress"); } try { const result = await processor(); // Store result (overwrites "processing" marker) await redis.set(key, JSON.stringify(result), "EX", 86400); return result; } catch (error) { // Remove lock on failure (allow retry) await redis.del(key); throw error; }} // =========================================// Solution 2: Processing timeout with automatic expiry// ========================================= interface IdempotencyRecordWithExpiry { key: string; status: "processing" | "completed" | "failed"; response?: unknown; processingExpiresAt?: number; // Epoch ms createdAt: number;} async function checkWithProcessingTimeout( store: Map<string, IdempotencyRecordWithExpiry>, key: string): Promise<"new" | "completed" | "in_progress" | "stale"> { const record = store.get(key); if (!record) return "new"; if (record.status === "completed") return "completed"; if (record.status === "processing") { // Check if processing has expired (zombie detection) if (record.processingExpiresAt && Date.now() > record.processingExpiresAt) { // Processing timed out - treat as stale return "stale"; } return "in_progress"; } return "new";} // =========================================// Solution 3: Lease-based processing with heartbeats// ========================================= // For long-running operations, use a lease that must be renewed class LeaseBasedIdempotency { private leases = new Map<string, { holder: string; expiresAt: number }>(); private results = new Map<string, unknown>(); private readonly leaseDurationMs = 30000; // 30 second lease /** * Try to acquire a lease for processing. * Returns lease holder ID if acquired, null if held by someone else. */ acquireLease(key: string): string | null { const existing = this.leases.get(key); const now = Date.now(); // Check if there's an active lease if (existing && existing.expiresAt > now) { return null; // Lease held by another processor } // Acquire or takeover expired lease const holderId = Math.random().toString(36).slice(2); this.leases.set(key, { holder: holderId, expiresAt: now + this.leaseDurationMs, }); return holderId; } /** * Renew a lease (call periodically during long processing). */ renewLease(key: string, holderId: string): boolean { const lease = this.leases.get(key); if (!lease || lease.holder !== holderId) { return false; // Not our lease } lease.expiresAt = Date.now() + this.leaseDurationMs; return true; } /** * Complete processing and store result. */ complete(key: string, holderId: string, result: unknown): boolean { const lease = this.leases.get(key); if (!lease || lease.holder !== holderId) { return false; // Lost our lease - someone else may have completed } this.results.set(key, result); this.leases.delete(key); return true; } /** * Get completed result. */ getResult(key: string): unknown | null { return this.results.get(key) ?? null; }} // Usage for long-running operationasync function processWithLease( idempotency: LeaseBasedIdempotency, key: string, longRunningOperation: (renewLease: () => boolean) => Promise<unknown>): Promise<unknown> { // Check for existing result const existing = idempotency.getResult(key); if (existing) return existing; // Try to acquire lease const holderId = idempotency.acquireLease(key); if (!holderId) { throw new Error("Processing in progress"); } try { // Execute with lease renewal capability const result = await longRunningOperation( () => idempotency.renewLease(key, holderId) ); // Complete with result const success = idempotency.complete(key, holderId, result); if (!success) { // Lost lease during completion - check if result was stored anyway const storedResult = idempotency.getResult(key); if (storedResult) return storedResult; throw new Error("Lost lease before completing"); } return result; } catch (error) { // Release lease on failure idempotency.renewLease(key, holderId); // Reset expiry // Then delete to allow retry throw error; }}Beyond implementing idempotency keys, you can design your API operations to be inherently more idempotent through careful design choices.
| Non-Idempotent | Idempotent Alternative | Approach |
|---|---|---|
| POST /orders (create) | PUT /orders/:clientOrderId | Client-provided ID |
| POST /counter/increment | PUT /counter (value: 5) | Absolute value, not delta |
| POST /account/deposit {$100} | PUT /transactions/:txnId {$100} | Transaction ID for each deposit |
| DELETE /item (oldest) | DELETE /items/:id | Specific ID, not relative position |
| POST /queue/enqueue | PUT /queue/items/:msgId | Message ID for dedup |
| PATCH /user (add role) | PUT /user/roles [list] | Full state, not delta |
SET balance = 500 is idempotent; INCREMENT balance BY 100 is not.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
/** * Example: Designing an Idempotent Payment API */ // =========================================// BEFORE: Non-idempotent design// ========================================= // POST /payments// Body: { amount: 100, customerId: "cust_123" }// Returns: { paymentId: "pay_abc", status: "succeeded" }//// Problem: Each POST creates a new payment!// Retry = double charge // =========================================// AFTER: Idempotent by design// ========================================= // PUT /payments/:clientPaymentId// Body: { amount: 100, customerId: "cust_123" }// Returns: { paymentId: "pay_abc", clientPaymentId: "my-pmt-001", status: "succeeded" }//// Client provides the ID. PUT to same ID is idempotent.// Server returns same result for same clientPaymentId. interface PaymentApiDesign { /** * Create or retrieve a payment by client-provided ID. * * Idempotency is built into the API design: * - PUT to the same ID always returns the same payment * - No need for separate Idempotency-Key header * * @param clientPaymentId - Client-generated unique ID (UUID recommended) * @param amount - Payment amount in cents * @param customerId - Customer identifier * * @returns PaymentResult - Same result for same clientPaymentId */ createPayment( clientPaymentId: string, amount: number, customerId: string ): Promise<PaymentResult>;} class IdempotentPaymentService implements PaymentApiDesign { private payments = new Map<string, PaymentResult>(); async createPayment( clientPaymentId: string, amount: number, customerId: string ): Promise<PaymentResult> { // Check if payment already exists const existing = this.payments.get(clientPaymentId); if (existing) { // Validate that request matches (optional but recommended) if (existing.amount !== amount) { throw new Error("Payment already exists with different amount"); } // Return existing payment (idempotent response) return existing; } // Create new payment const result: PaymentResult = { paymentId: `pay_${Math.random().toString(36).slice(2)}`, clientPaymentId, amount, status: "succeeded", }; // Store for future idempotent retrieval this.payments.set(clientPaymentId, result); return result; }} interface PaymentResult { paymentId: string; clientPaymentId: string; amount: number; status: string;} // =========================================// Conditional Updates with ETags// ========================================= interface UserResource { id: string; email: string; version: number; // Used as ETag} async function updateUserIdempotently( userId: string, newEmail: string, ifMatch: number // ETag from previous GET): Promise<UserResource> { const user = await getUser(userId); if (user.version !== ifMatch) { // Conflicting update or stale client throw new Error("Precondition Failed: version mismatch"); } // Update with version increment user.email = newEmail; user.version++; await saveUser(user); return user;} // This pattern is idempotent because:// - Same request with same ETag succeeds once, then fails with 412// - Client knows to GET again before retrying// - Prevents lost updates AND supports safe retry async function getUser(id: string): Promise<UserResource> { // Stub implementation return { id, email: "old@email.com", version: 1 };} async function saveUser(user: UserResource): Promise<void> { // Stub implementation}Let's examine how major platforms implement idempotency in their APIs.
Idempotency-Key header. Keys are stored for 24 hours. Returns 400 if same key used with different request body. Provides stripe-idempotency-replay: true header when returning cached response.clientToken parameter. For Lambda, guarantees at-least-once delivery (consumers must be idempotent).messageId for publisher-side deduplication. Subscribers use acknowledgment IDs. Exactly-once delivery mode uses ordering keys.PayPal-Request-Id header. Caches results for 72 hours. Returns PayPal-Debug-Id for cached responses to aid debugging.X-Shopify-Idempotency-Key for POST requests. Keys must be 6-64 characters. Stored for 24 hours. 422 error if key reused with different request.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
/** * Stripe-Style Idempotency Implementation * * Mimics Stripe's well-designed idempotency system. */ interface StripeStyleIdemp { key: string; requestHash: string; response: unknown; statusCode: number; createdAt: Date; expiresAt: Date; inProgress: boolean;} class StripeStyleIdempotencyHandler { private records = new Map<string, StripeStyleIdemp>(); private readonly TTL_HOURS = 24; /** * Process request with Stripe-style idempotency handling. * * Returns: * - { replay: false, response } for new requests * - { replay: true, response } for cached responses * - Error for conflicts */ async handleRequest<T>( idempotencyKey: string | undefined, requestBody: unknown, processor: () => Promise<{ statusCode: number; body: T }> ): Promise<{ replay: boolean; statusCode: number; body: T }> { // No key = process normally (not idempotent) if (!idempotencyKey) { const result = await processor(); return { replay: false, ...result }; } const requestHash = this.hashRequest(requestBody); const existing = this.records.get(idempotencyKey); // Check for existing record if (existing) { // Validate request matches if (existing.requestHash !== requestHash) { throw new IdempotencyKeyConflictError( "Keys for mutually exclusive requests can only be used once" ); } // Check if still processing if (existing.inProgress) { throw new IdempotencyKeyProcessingError( "A request with this idempotency key is currently being processed" ); } // Return cached response return { replay: true, statusCode: existing.statusCode, body: existing.response as T, }; } // New request - create record first const record: StripeStyleIdemp = { key: idempotencyKey, requestHash, response: null, statusCode: 0, createdAt: new Date(), expiresAt: new Date(Date.now() + this.TTL_HOURS * 60 * 60 * 1000), inProgress: true, }; this.records.set(idempotencyKey, record); try { // Process the request const result = await processor(); // Cache the result record.response = result.body; record.statusCode = result.statusCode; record.inProgress = false; return { replay: false, ...result }; } catch (error) { // Remove record on error (allow retry) this.records.delete(idempotencyKey); throw error; } } private hashRequest(body: unknown): string { const crypto = require("crypto"); const normalized = JSON.stringify(body, Object.keys(body as object).sort()); return crypto.createHash("md5").update(normalized).digest("hex"); }} class IdempotencyKeyConflictError extends Error { constructor(message: string) { super(message); this.name = "IdempotencyKeyConflictError"; }} class IdempotencyKeyProcessingError extends Error { constructor(message: string) { super(message); this.name = "IdempotencyKeyProcessingError"; }} // =========================================// Express Middleware Example// ========================================= import type { Request, Response, NextFunction } from "express"; const idempotencyHandler = new StripeStyleIdempotencyHandler(); async function idempotencyMiddleware( req: Request, res: Response, next: NextFunction) { const idempotencyKey = req.headers["idempotency-key"] as string | undefined; // Only applies to POST/PATCH requests if (!["POST", "PATCH"].includes(req.method)) { return next(); } try { const result = await idempotencyHandler.handleRequest( idempotencyKey, req.body, async () => { // Execute the actual handler and capture response // This is simplified - real implementation needs response capture return new Promise((resolve) => { // Store original methods const originalJson = res.json.bind(res); res.json = (body: unknown) => { resolve({ statusCode: res.statusCode, body }); return originalJson(body); }; next(); }); } ); if (result.replay) { // Add header indicating this is a replay res.setHeader("Idempotency-Replay", "true"); res.status(result.statusCode).json(result.body); } // else: next() was called, response sent normally } catch (error) { if (error instanceof IdempotencyKeyConflictError) { res.status(400).json({ error: error.message }); } else if (error instanceof IdempotencyKeyProcessingError) { res.status(409).json({ error: error.message }); } else { next(error); } }}Idempotency is the foundation that makes retries safe. Without idempotent operations, all the retry strategies we've learned—backoff, jitter, budgets—become dangerous. With idempotency, they become powerful tools for reliability.
Module Complete!
You've now mastered the complete retry strategies toolkit:
Together, these concepts form the foundation of resilient distributed system communication.
Congratulations! You now possess comprehensive knowledge of retry strategies in distributed systems. You understand when to retry, how to implement exponential backoff with jitter, how to prevent retry amplification with budgets, and how to ensure retry safety through idempotency. These are the building blocks of fault-tolerant systems.