Loading learning content...
Consider this scenario: A customer clicks "Submit Payment" on your checkout page. The request travels to your payment service, successfully charges their credit card, but the response times out before reaching the client. The client's retry logic dutifully retries the payment. Without idempotency protection, the customer is charged twice.
This isn't a hypothetical edge case—it's a certainty at scale. With millions of transactions, network timeouts, and aggressive retry policies, duplicate execution of non-idempotent operations will happen. The consequences range from embarrassing (duplicate emails) to catastrophic (double-charged payments, oversold inventory, corrupted financial records).
Idempotency is the property that ensures an operation can be applied multiple times without changing the result beyond the initial application. For retry logic to be safe for write operations, idempotency isn't optional—it's mandatory. This page explores why idempotency matters, how to implement it correctly, and the patterns that make operations safe to retry no matter how many times they're executed.
By the end of this page, you will understand the relationship between retries and idempotency, master idempotency key design and implementation, recognize naturally idempotent operations, know how to make non-idempotent operations safe, understand the trade-offs of different idempotency strategies, and apply these patterns to real-world systems.
Idempotency is a mathematical concept borrowed from algebra: an operation is idempotent if applying it multiple times produces the same result as applying it once.
Mathematical Definition:
f(f(x)) = f(x)
In distributed systems, idempotency means that sending the same request multiple times (intentionally or via retry) produces the same state and response as sending it once.
Why Retries Demand Idempotency
The fundamental problem with retries and write operations is the ambiguous timeout:
From the client's perspective: Did the operation succeed or fail? It's impossible to know. The safe action is to retry.
From the server's perspective: The operation completed successfully. But when the retry arrives, should it:
| Operation | Naturally Idempotent? | Explanation |
|---|---|---|
| GET /users/123 | Yes | Reading state doesn't modify it |
| DELETE /users/123 | Yes* | Deleting twice = deleting once (same end state) |
| PUT /users/123 {name: "Alice"} | Yes | Setting absolute value is idempotent |
| POST /users {name: "Alice"} | No | Creates new resource each time |
| POST /users/123/increment-counter | No | Each execution adds to counter |
| POST /payments {amount: 100} | No | Each execution charges $100 |
| PUT /users/123/counter = 5 | Yes | Sets counter to absolute value |
| PATCH /users/123 {age: 30} | Yes* | Setting absolute value is idempotent |
*With caveats: DELETE of non-existent resource may return 404 on retry (semantically idempotent but different response); PATCH depends on whether changes are absolute or relative.
Key Insight: Response Consistency
For retry-safe idempotency, not only should the state be the same, but ideally the response should also be consistent. If the first request returns {id: 123, created: true} and a retry returns {error: "already exists"}, the client receives confusing signals even though state is consistent.
Even naturally idempotent operations can become non-idempotent if time passes between executions. DELETE /resource/123 is idempotent, but if between first and second attempt, a new resource with ID 123 is created, the retry deletes a different entity. Time-sensitive idempotency requires additional safeguards.
The most robust approach to ensuring idempotency is the idempotency key pattern: a unique identifier generated by the client and included with each request. The server uses this key to detect and handle duplicate requests.
How It Works:
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
// Server-side idempotency key implementationimport { v4 as uuidv4 } from 'uuid'; interface IdempotencyRecord { key: string; status: 'processing' | 'completed' | 'failed'; response?: { statusCode: number; body: unknown; headers: Record<string, string>; }; createdAt: Date; expiresAt: Date;} class IdempotencyStore { private store = new Map<string, IdempotencyRecord>(); private readonly defaultTTLMs = 24 * 60 * 60 * 1000; // 24 hours /** * Check if we have a result for this idempotency key. * Sets to 'processing' if not found (atomic operation in production). */ async getOrSetProcessing(key: string): Promise<IdempotencyRecord | null> { const existing = this.store.get(key); if (existing) { // Key found - return existing record return existing; } // Key not found - create new record in 'processing' state // In production: atomic compare-and-set operation const newRecord: IdempotencyRecord = { key, status: 'processing', createdAt: new Date(), expiresAt: new Date(Date.now() + this.defaultTTLMs), }; this.store.set(key, newRecord); return null; // null indicates this is the first request } /** * Store completed result for replay */ async setCompleted( key: string, response: IdempotencyRecord['response'] ): Promise<void> { const record = this.store.get(key); if (record) { record.status = 'completed'; record.response = response; } } /** * Mark as failed if operation fails */ async setFailed(key: string): Promise<void> { const record = this.store.get(key); if (record) { record.status = 'failed'; } } /** * Remove processing lock (for cleanup on failure) */ async remove(key: string): Promise<void> { this.store.delete(key); }} // Express-style middleware for idempotencyasync function idempotencyMiddleware( req: Request, operation: () => Promise<Response>): Promise<Response> { const idempotencyKey = req.headers.get('Idempotency-Key'); // If no key provided, execute without idempotency protection // (Consider requiring keys for all POST/PATCH/DELETE) if (!idempotencyKey) { console.warn('No idempotency key provided for state-changing request'); return await operation(); } // Validate key format (UUID) if (!isValidUUID(idempotencyKey)) { return new Response(JSON.stringify({ error: 'invalid_idempotency_key', message: 'Idempotency-Key must be a valid UUID', }), { status: 400 }); } const store = getIdempotencyStore(); const existing = await store.getOrSetProcessing(idempotencyKey); if (existing) { // We've seen this key before switch (existing.status) { case 'completed': // Return cached response (idempotent replay) console.log(`Idempotency replay: ${idempotencyKey}`); return new Response( JSON.stringify(existing.response?.body), { status: existing.response?.statusCode || 200, headers: new Headers({ 'Content-Type': 'application/json', 'Idempotent-Replayed': 'true', ...existing.response?.headers, }), } ); case 'processing': // Another request is currently processing // Return 409 Conflict or 503 with Retry-After return new Response(JSON.stringify({ error: 'request_in_progress', message: 'Another request with this idempotency key is in progress', }), { status: 409, headers: { 'Retry-After': '1' }, }); case 'failed': // Previous attempt failed - allow retry await store.remove(idempotencyKey); // Fall through to execute } } // Execute the operation try { const response = await operation(); // Store successful response for replay const body = await response.clone().json(); await store.setCompleted(idempotencyKey, { statusCode: response.status, body, headers: Object.fromEntries(response.headers.entries()), }); return response; } catch (error) { await store.setFailed(idempotencyKey); throw error; }} function isValidUUID(str: string): boolean { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; return uuidRegex.test(str);} declare function getIdempotencyStore(): IdempotencyStore;Stripe's payment API is the gold standard for idempotency key implementation. They require Idempotency-Key headers for all POST requests, store results for 24 hours, and return identical responses for replays. Study their documentation as a reference implementation.
Proper client-side key management is essential. The client must correctly generate, store, and reuse keys across retry attempts while generating new keys for distinct operations.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
// Client-side idempotency key management with retry logicimport { v4 as uuidv4 } from 'uuid'; interface RequestWithIdempotency<T> { url: string; method: 'POST' | 'PUT' | 'PATCH' | 'DELETE'; body?: T; headers?: Record<string, string>;} interface IdempotentRequestContext<TReq, TRes> { request: RequestWithIdempotency<TReq>; idempotencyKey: string; execute(): Promise<TRes>;} /** * Create an idempotent request context that can be retried safely. * The idempotency key is generated once and reused for all retry attempts. */function createIdempotentRequest<TReq, TRes>( request: RequestWithIdempotency<TReq>): IdempotentRequestContext<TReq, TRes> { // Generate key once at request creation time const idempotencyKey = uuidv4(); return { request, idempotencyKey, async execute(): Promise<TRes> { const response = await fetch(request.url, { method: request.method, headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey, ...request.headers, }, body: request.body ? JSON.stringify(request.body) : undefined, }); if (!response.ok) { throw new HttpError(response.status, await response.text()); } return response.json(); }, };} /** * Execute a request with automatic retry and preserved idempotency key */async function executeWithIdempotentRetry<TReq, TRes>( request: RequestWithIdempotency<TReq>, retryConfig: { maxAttempts: number; baseDelayMs: number; maxDelayMs: number; isRetryable: (error: Error) => boolean; }): Promise<TRes> { // Create context with idempotency key BEFORE any attempts const context = createIdempotentRequest<TReq, TRes>(request); console.log(`Starting idempotent request: key=${context.idempotencyKey}`); let lastError: Error | undefined; for (let attempt = 1; attempt <= retryConfig.maxAttempts; attempt++) { try { const result = await context.execute(); console.log( `Request succeeded on attempt ${attempt}: key=${context.idempotencyKey}` ); return result; } catch (error) { lastError = error as Error; if (!retryConfig.isRetryable(lastError)) { throw lastError; } if (attempt < retryConfig.maxAttempts) { const delay = Math.min( retryConfig.baseDelayMs * Math.pow(2, attempt - 1), retryConfig.maxDelayMs ); console.log( `Attempt ${attempt} failed, retrying in ${delay}ms: ` + `key=${context.idempotencyKey}` ); await sleep(delay); } } } throw new Error( `Request failed after ${retryConfig.maxAttempts} attempts ` + `(idempotencyKey: ${context.idempotencyKey}): ${lastError?.message}` );} // Usage example: Creating a payment with safe retriesasync function createPayment(amount: number, customerId: string) { return executeWithIdempotentRetry( { url: 'https://api.example.com/v1/payments', method: 'POST', body: { amount, currency: 'usd', customer: customerId, }, }, { maxAttempts: 5, baseDelayMs: 200, maxDelayMs: 10000, isRetryable: (error) => { if (error instanceof HttpError) { // Retry on transient errors only return [408, 429, 500, 502, 503, 504].includes(error.status); } return error.name === 'NetworkError'; }, } );} // IMPORTANT: Each user action should create a new idempotency key// DO NOT reuse keys across distinct business operations // Correct: User clicks "Pay" - new key for this payment attemptconst payButton = document.getElementById('pay-button');payButton?.addEventListener('click', async () => { // Each click is a new user intent, gets a new key try { const result = await createPayment(100, 'cust_123'); showSuccess(result); } catch (error) { showError(error); }}); class HttpError extends Error { constructor(public status: number, message: string) { super(`HTTP ${status}: ${message}`); this.name = 'HttpError'; }} function sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms));} declare function showSuccess(result: unknown): void;declare function showError(error: unknown): void;The idempotency key should represent the user's business intent, not the technical request. If a user clicks "Buy" twice (two intents), those should be two different keys. If network retry logic fires three times for a single click (one intent), those should all use the same key. Confusing these leads to either lost transactions or duplicate execution.
Some operations are inherently non-idempotent but still need to be made safe for retry. Here are patterns for common scenarios.
Pattern 1: Counter Increments
Instead of "increment by 1," track already-processed increments:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
// Pattern 1: Making counter increments idempotentinterface IncrementRequest { idempotencyKey: string; incrementBy: number;} class IdempotentCounter { private counter: number = 0; private processedIncrements = new Set<string>(); increment(request: IncrementRequest): number { // Check if we've already processed this increment if (this.processedIncrements.has(request.idempotencyKey)) { console.log(`Increment ${request.idempotencyKey} already processed, returning current value`); return this.counter; } // Process the increment this.counter += request.incrementBy; this.processedIncrements.add(request.idempotencyKey); return this.counter; }} // Pattern 2: Auto-increment IDs → Client-generated IDs// WRONG: Server generates ID on each requestasync function createUserWrong(name: string) { // If retried, creates duplicate users with different IDs const id = await generateAutoIncrementId(); await db.users.create({ id, name }); return { id, name };} // RIGHT: Client provides ID, server rejects duplicatesasync function createUserRight(request: { id: string; name: string }) { // If exists, return existing (idempotent) const existing = await db.users.findById(request.id); if (existing) { return existing; } // If not exists, create // Use unique constraint to handle race condition try { const user = await db.users.create(request); return user; } catch (error) { if (isUniqueConstraintError(error)) { // Race condition: another request created it return await db.users.findById(request.id); } throw error; }} // Pattern 3: Financial transactions with idempotencyinterface PaymentRequest { idempotencyKey: string; amount: number; customerId: string;} async function processPayment(request: PaymentRequest) { // Check for existing transaction with this key const existing = await db.transactions.findByIdempotencyKey(request.idempotencyKey); if (existing) { // Return the existing result (idempotent replay) return { id: existing.id, amount: existing.amount, status: existing.status, replayed: true, }; } // Create transaction record BEFORE charging const transaction = await db.transactions.create({ idempotencyKey: request.idempotencyKey, amount: request.amount, customerId: request.customerId, status: 'pending', }); try { // Charge the payment processor const charge = await paymentProcessor.charge({ amount: request.amount, customerId: request.customerId, metadata: { transactionId: transaction.id }, }); // Update transaction status await db.transactions.update(transaction.id, { status: 'completed', chargeId: charge.id, }); return { id: transaction.id, amount: request.amount, status: 'completed', chargeId: charge.id, }; } catch (error) { // Mark transaction as failed await db.transactions.update(transaction.id, { status: 'failed', error: String(error), }); throw error; }} // Pattern 4: Send-once operations (emails, notifications)interface NotificationRequest { idempotencyKey: string; userId: string; message: string;} async function sendNotification(request: NotificationRequest) { // Check if already sent const existing = await db.sentNotifications.findByIdempotencyKey( request.idempotencyKey ); if (existing) { return { sent: true, deduplicated: true }; } // Record intent to send BEFORE sending // (Prevents gap where send succeeds but recording fails) await db.sentNotifications.create({ idempotencyKey: request.idempotencyKey, userId: request.userId, status: 'sending', }); try { await notificationService.send(request.userId, request.message); await db.sentNotifications.update(request.idempotencyKey, { status: 'sent', }); return { sent: true, deduplicated: false }; } catch (error) { await db.sentNotifications.update(request.idempotencyKey, { status: 'failed', }); throw error; }} // Helper declarationsdeclare function generateAutoIncrementId(): Promise<number>;declare function isUniqueConstraintError(error: unknown): boolean;declare const db: { users: any; transactions: any; sentNotifications: any;};declare const paymentProcessor: any;declare const notificationService: any;| Operation Type | Non-Idempotent Problem | Idempotent Solution |
|---|---|---|
| Counter increment | Each retry adds again | Track processed increment IDs |
| Resource creation (auto-ID) | Creates duplicates with new IDs | Client-provided IDs + upsert |
| Financial transaction | Double charge | Idempotency key + transaction record |
| Email/notification send | Duplicate messages | Deduplication table checked before send |
| Inventory decrement | Over-decrement | Reservation + commit pattern or idempotent tokens |
| State machine transition | Duplicate transitions | Version/ETag conditional updates |
Idempotency records require careful storage design. They must be durable, fast to query, and manageable at scale.
Storage Requirements:
Storage Options:
| Storage | Pros | Cons | Best For |
|---|---|---|---|
| Redis | Fast, TTL native, atomic ops | Durability depends on config | High-throughput APIs |
| PostgreSQL/MySQL | Durable, transactional | Slower than cache, needs TTL job | Financial transactions |
| DynamoDB | Scalable, TTL native | Cost, eventual consistency | Serverless, high scale |
| Application DB table | Simple, same transaction | Slow if not indexed | Lower throughput |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
// Redis-based idempotency storage with atomic operationsimport Redis from 'ioredis'; interface IdempotencyResult { status: 'new' | 'processing' | 'completed' | 'failed'; response?: { statusCode: number; body: string; };} class RedisIdempotencyStore { private redis: Redis; private defaultTTLSeconds = 86400; // 24 hours constructor(redisUrl: string) { this.redis = new Redis(redisUrl); } /** * Atomically acquire processing lock or retrieve existing result. * Uses Redis SETNX for atomic check-and-set. */ async getOrLock(key: string): Promise<{ isLocked: boolean; existingResult?: IdempotencyResult; }> { const existingRaw = await this.redis.get(`idempotency:${key}`); if (existingRaw) { const existing: IdempotencyResult = JSON.parse(existingRaw); return { isLocked: false, existingResult: existing }; } // Attempt to acquire lock (SETNX = Set if Not eXists) const lockKey = `idempotency:${key}`; const lockValue = JSON.stringify({ status: 'processing' }); // SET with NX (only if not exists) and EX (expire) const result = await this.redis.set( lockKey, lockValue, 'EX', this.defaultTTLSeconds, 'NX' ); if (result === 'OK') { // We acquired the lock return { isLocked: true }; } else { // Someone else acquired between our GET and SET // Fetch their result const nowExisting = await this.redis.get(lockKey); if (nowExisting) { return { isLocked: false, existingResult: JSON.parse(nowExisting) }; } // Race condition edge case - retry return this.getOrLock(key); } } /** * Store completed result */ async setCompleted( key: string, response: { statusCode: number; body: string } ): Promise<void> { const result: IdempotencyResult = { status: 'completed', response, }; await this.redis.setex( `idempotency:${key}`, this.defaultTTLSeconds, JSON.stringify(result) ); } /** * Mark as failed (allows retry) */ async setFailed(key: string): Promise<void> { const result: IdempotencyResult = { status: 'failed' }; await this.redis.setex( `idempotency:${key}`, this.defaultTTLSeconds, JSON.stringify(result) ); } /** * Remove lock (cleanup on failure before response stored) */ async removeLock(key: string): Promise<void> { await this.redis.del(`idempotency:${key}`); }} // PostgreSQL-based for stronger durability (financial use cases)// Using a dedicated table with proper indexing /*CREATE TABLE idempotency_records ( idempotency_key VARCHAR(255) PRIMARY KEY, status VARCHAR(20) NOT NULL DEFAULT 'processing', response_status_code INTEGER, response_body JSONB, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), expires_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + INTERVAL '24 hours'); CREATE INDEX idx_idempotency_expires ON idempotency_records(expires_at); -- Periodic cleanup jobDELETE FROM idempotency_records WHERE expires_at < NOW();*/ class PostgresIdempotencyStore { constructor(private pool: any) {} async getOrLock(key: string): Promise<{ isLocked: boolean; existingResult?: IdempotencyResult; }> { // Use INSERT ... ON CONFLICT for atomic check-and-set const result = await this.pool.query(` INSERT INTO idempotency_records (idempotency_key, status) VALUES ($1, 'processing') ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key RETURNING idempotency_key, status, response_status_code, response_body, (xmax = 0) as is_new `, [key]); const row = result.rows[0]; if (row.is_new) { return { isLocked: true }; } return { isLocked: false, existingResult: { status: row.status, response: row.response_status_code ? { statusCode: row.response_status_code, body: JSON.stringify(row.response_body), } : undefined, }, }; } async setCompleted( key: string, response: { statusCode: number; body: string } ): Promise<void> { await this.pool.query(` UPDATE idempotency_records SET status = 'completed', response_status_code = $2, response_body = $3, updated_at = NOW() WHERE idempotency_key = $1 `, [key, response.statusCode, response.body]); } async setFailed(key: string): Promise<void> { await this.pool.query(` UPDATE idempotency_records SET status = 'failed', updated_at = NOW() WHERE idempotency_key = $1 `, [key]); }}Idempotency record TTL should exceed the maximum expected retry window by a comfortable margin. If your retry logic might retry for up to 5 minutes, keep records for 24+ hours. This handles cases where operation succeeds, retry still in flight, and client eventually receives stale success. Too short TTL = duplicate execution when old record expires mid-retry.
In microservices architectures, a single user operation often spans multiple services. Each service needs to handle idempotency, and keys must be coordinated across boundaries.
Propagating Idempotency Keys
When Service A calls Service B as part of processing a request:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
// Idempotency key derivation for distributed operationsimport { createHash } from 'crypto'; /** * Derive a child idempotency key from a parent key. * Ensures deterministic, unique keys for downstream operations. */function deriveIdempotencyKey( parentKey: string, operationType: string, context?: Record<string, string>): string { const components = [ parentKey, operationType, ...Object.entries(context || {}).map(([k, v]) => `${k}=${v}`), ]; return createHash('sha256') .update(components.join(':')) .digest('hex') .slice(0, 36); // UUID-length for compatibility} // Example: Order processing spanning multiple servicesinterface OrderRequest { idempotencyKey: string; customerId: string; items: Array<{ productId: string; quantity: number }>; paymentMethodId: string;} class OrderService { async processOrder(request: OrderRequest) { // Parent idempotency key from client const parentKey = request.idempotencyKey; // Step 1: Create order record (with parent key) const order = await this.createOrder(request); // Step 2: Reserve inventory (derived key) const inventoryKey = deriveIdempotencyKey(parentKey, 'inventory-reserve', { orderId: order.id, }); await this.inventoryService.reserve({ idempotencyKey: inventoryKey, items: request.items, }); // Step 3: Process payment (derived key) const paymentKey = deriveIdempotencyKey(parentKey, 'payment-charge', { orderId: order.id, }); const payment = await this.paymentService.charge({ idempotencyKey: paymentKey, amount: order.total, customerId: request.customerId, paymentMethodId: request.paymentMethodId, }); // Step 4: Send confirmation email (derived key) const emailKey = deriveIdempotencyKey(parentKey, 'email-confirmation', { orderId: order.id, }); await this.notificationService.sendOrderConfirmation({ idempotencyKey: emailKey, orderId: order.id, email: order.customerEmail, }); return { orderId: order.id, paymentId: payment.id, status: 'confirmed', }; } private async createOrder(request: OrderRequest) { // Order creation is idempotent via the parent key // ... return { id: 'order-123', total: 100, customerEmail: 'user@example.com' }; } private inventoryService = { reserve: async (req: { idempotencyKey: string; items: any[] }) => {}, }; private paymentService = { charge: async (req: { idempotencyKey: string; amount: number; customerId: string; paymentMethodId: string }) => ({ id: 'payment-123' }), }; private notificationService = { sendOrderConfirmation: async (req: { idempotencyKey: string; orderId: string; email: string }) => {}, };} // Key derivation ensures:// 1. Same parent key → same derived keys → idempotent across all services// 2. Different operations get different keys (operationType discriminator)// 3. If order 1 and order 2 have same parent key, they derive same downstream keys// 4. If order 1 retries, all downstream calls use same keys as first attempt // Example trace:// Parent key: "abc-123"// → Inventory key: sha256("abc-123:inventory-reserve:orderId=order-123") → "7f3d..."// → Payment key: sha256("abc-123:payment-charge:orderId=order-123") → "2a5e..."// → Email key: sha256("abc-123:email-confirmation:orderId=order-123") → "9c1b..."When calling external APIs (payment processors, shipping providers), use their idempotency mechanism if available. Don't assume your derived keys will work with their systems. Many external APIs require specific key formats or have their own idempotency semantics.
Idempotency is a critical safety property that must be verified through testing. Here are patterns for comprehensive idempotency testing.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
// Comprehensive idempotency test patternsimport { describe, it, expect, beforeEach } from 'vitest'; describe('Payment API Idempotency', () => { let api: PaymentAPI; beforeEach(() => { api = new PaymentAPI(); }); // Test 1: Basic duplicate detection it('should return same response for duplicate requests', async () => { const idempotencyKey = 'test-key-1'; const request = { amount: 100, customerId: 'cust-1' }; const response1 = await api.createPayment({ ...request, idempotencyKey }); const response2 = await api.createPayment({ ...request, idempotencyKey }); // Same response expect(response2.paymentId).toBe(response1.paymentId); expect(response2.amount).toBe(response1.amount); // Only one actual payment expect(api.getPaymentCount()).toBe(1); }); // Test 2: Different keys create different resources it('should create separate resources for different keys', async () => { const request = { amount: 100, customerId: 'cust-1' }; const response1 = await api.createPayment({ ...request, idempotencyKey: 'key-a' }); const response2 = await api.createPayment({ ...request, idempotencyKey: 'key-b' }); // Different payments expect(response2.paymentId).not.toBe(response1.paymentId); expect(api.getPaymentCount()).toBe(2); }); // Test 3: Concurrent duplicate requests it('should handle concurrent duplicates safely', async () => { const idempotencyKey = 'concurrent-test'; const request = { amount: 100, customerId: 'cust-1' }; // Fire 10 concurrent requests with same key const promises = Array(10).fill(null).map(() => api.createPayment({ ...request, idempotencyKey }) ); const responses = await Promise.all(promises); // All should return same payment ID const paymentIds = responses.map(r => r.paymentId); expect(new Set(paymentIds).size).toBe(1); // Only one payment created expect(api.getPaymentCount()).toBe(1); }); // Test 4: Request body mismatch detection it('should reject duplicate key with different body', async () => { const idempotencyKey = 'mismatch-test'; await api.createPayment({ amount: 100, customerId: 'cust-1', idempotencyKey }); // Same key, different amount await expect( api.createPayment({ amount: 200, customerId: 'cust-1', idempotencyKey }) ).rejects.toThrow('Idempotency key already used with different request'); }); // Test 5: Failed request allows retry it('should allow retry after failed request', async () => { const idempotencyKey = 'retry-after-failure'; const request = { amount: 100, customerId: 'cust-1' }; // First request fails api.simulateFailure(true); await expect( api.createPayment({ ...request, idempotencyKey }) ).rejects.toThrow(); // Retry should be allowed and succeed api.simulateFailure(false); const response = await api.createPayment({ ...request, idempotencyKey }); expect(response.paymentId).toBeDefined(); expect(api.getPaymentCount()).toBe(1); }); // Test 6: Processing state handles slow operations it('should return 409 when request is still processing', async () => { const idempotencyKey = 'slow-operation'; const request = { amount: 100, customerId: 'cust-1' }; // Start a slow operation api.setProcessingDelay(5000); const slowPromise = api.createPayment({ ...request, idempotencyKey }); // Immediate retry should get 409 await new Promise(resolve => setTimeout(resolve, 100)); await expect( api.createPayment({ ...request, idempotencyKey }) ).rejects.toThrow('Request in progress'); // Original completes const response = await slowPromise; expect(response.paymentId).toBeDefined(); }); // Test 7: Response consistency it('should return identical response structure on replay', async () => { const idempotencyKey = 'response-test'; const request = { amount: 100, customerId: 'cust-1' }; const response1 = await api.createPayment({ ...request, idempotencyKey }); const response2 = await api.createPayment({ ...request, idempotencyKey }); // Deep equality including all fields expect(response2).toEqual({ ...response1, // May include a 'replayed' flag }); });}); // Mock API for testingclass PaymentAPI { private payments: Map<string, any> = new Map(); private idempotencyRecords: Map<string, any> = new Map(); private shouldFail = false; private delayMs = 0; async createPayment(request: { amount: number; customerId: string; idempotencyKey: string; }) { // Check idempotency const existing = this.idempotencyRecords.get(request.idempotencyKey); if (existing) { if (existing.status === 'processing') { throw new Error('Request in progress'); } if (existing.status === 'completed') { // Verify request body matches if (existing.requestHash !== this.hashRequest(request)) { throw new Error('Idempotency key already used with different request'); } return existing.response; } // Failed - allow retry } // Record processing state this.idempotencyRecords.set(request.idempotencyKey, { status: 'processing', requestHash: this.hashRequest(request), }); // Simulate processing delay if (this.delayMs > 0) { await new Promise(resolve => setTimeout(resolve, this.delayMs)); } // Simulate failure if (this.shouldFail) { this.idempotencyRecords.set(request.idempotencyKey, { status: 'failed' }); throw new Error('Payment processing failed'); } // Create payment const paymentId = `pay_${Date.now()}`; this.payments.set(paymentId, request); const response = { paymentId, amount: request.amount, customerId: request.customerId, status: 'succeeded', }; // Record success this.idempotencyRecords.set(request.idempotencyKey, { status: 'completed', requestHash: this.hashRequest(request), response, }); return response; } getPaymentCount(): number { return this.payments.size; } simulateFailure(shouldFail: boolean): void { this.shouldFail = shouldFail; } setProcessingDelay(ms: number): void { this.delayMs = ms; } private hashRequest(request: any): string { return JSON.stringify({ amount: request.amount, customerId: request.customerId }); }}Beyond unit tests, use chaos engineering to verify idempotency in production-like environments. Inject network failures, slow responses, and connection resets during operations. Verify that retries don't create duplicates and that the system state remains consistent. Tools like Gremlin, Chaos Mesh, or custom failure injection can simulate these conditions.
Idempotency is the critical safety mechanism that makes retry logic safe for write operations. Without it, retries can cause data corruption, duplicate charges, and inconsistent system state.
Module Complete: Retry with Backoff
You've now mastered the complete retry pattern:
These five components work together to create resilient systems that recover gracefully from transient failures while protecting against the cascading effects of retry storms and duplicate execution.
Congratulations! You've completed the Retry with Backoff module. You now have a comprehensive understanding of building resilient retry mechanisms in distributed systems—from understanding when retries help to ensuring they never cause harm through idempotency. These patterns form the backbone of reliable distributed systems at companies like Amazon, Google, Netflix, and Stripe.