Published on

Idempotency in Distributed Systems — Making Any Operation Safe to Retry

Authors

Introduction

Network failures are inevitable. A client retries a request, which succeeds, but the response is lost. Without idempotency, that retry creates a duplicate charge, duplicate order, or duplicate message. Idempotency is your shield: the same request executed multiple times produces the same result as executing once. We'll explore idempotency key design, storage strategies, and how to verify duplicates at scale.

Idempotency Key Design

An idempotency key uniquely identifies a request intent. It can be client-generated or deterministically derived.

Client-generated with UUID:

interface CreateChargeRequest {
  amount: number;
  currency: string;
  customerId: string;
  idempotencyKey: string; // Client provides UUID
}

class PaymentAPI {
  async createCharge(req: CreateChargeRequest): Promise<ChargeResponse> {
    const normalized = {
      method: 'POST',
      path: '/charges',
      body: req,
      idempotencyKey: req.idempotencyKey,
    };

    // Check if we've seen this idempotency key
    const cached = await this.idempotencyStore.get(req.idempotencyKey);
    if (cached) {
      return cached.response; // Return previous response
    }

    const charge = await this.stripe.charges.create({
      amount: req.amount,
      currency: req.currency,
      customer: req.customerId,
    });

    const response: ChargeResponse = {
      chargeId: charge.id,
      amount: charge.amount,
      status: charge.status,
    };

    // Store result with TTL (24 hours)
    await this.idempotencyStore.set(req.idempotencyKey, { response, timestamp: Date.now() }, 86400);

    return response;
  }
}

interface ChargeResponse {
  chargeId: string;
  amount: number;
  status: 'succeeded' | 'pending' | 'failed';
}

Deterministic hashing from request content:

import crypto from 'crypto';

class DeterministicIdempotency {
  generateKey(method: string, path: string, body: any): string {
    const normalized = JSON.stringify({
      method,
      path,
      body: this.normalizeBody(body),
    });

    return crypto.createHash('sha256').update(normalized).digest('hex');
  }

  private normalizeBody(body: any): any {
    // Ensure consistent ordering and structure
    if (Array.isArray(body)) {
      return body.map(item => this.normalizeBody(item));
    }
    if (typeof body === 'object' && body !== null) {
      return Object.keys(body)
        .sort()
        .reduce((acc, key) => {
          acc[key] = this.normalizeBody(body[key]);
          return acc;
        }, {} as Record<string, any>);
    }
    return body;
  }

  async createTransfer(req: any): Promise<any> {
    const idempotencyKey = this.generateKey('POST', '/transfers', req);

    const cached = await this.idempotencyStore.get(idempotencyKey);
    if (cached) {
      return cached;
    }

    const result = await this.processTransfer(req);
    await this.idempotencyStore.set(idempotencyKey, result, 86400);
    return result;
  }

  private async processTransfer(req: any): Promise<any> {
    // Implementation
    return {};
  }
}

Idempotency Store with TTL

Redis is ideal for idempotency stores: fast, supports TTL, and atomic operations.

class RedisIdempotencyStore {
  constructor(private redis: Redis) {}

  async get(key: string): Promise<any> {
    const value = await this.redis.get(`idempotent:${key}`);
    if (!value) return null;
    try {
      return JSON.parse(value);
    } catch {
      return null;
    }
  }

  async set(key: string, value: any, ttlSeconds: number): Promise<void> {
    const serialized = JSON.stringify({
      response: value,
      storedAt: Date.now(),
    });
    await this.redis.setex(`idempotent:${key}`, ttlSeconds, serialized);
  }

  async exists(key: string): Promise<boolean> {
    return (await this.redis.exists(`idempotent:${key}`)) === 1;
  }

  async delete(key: string): Promise<void> {
    await this.redis.del(`idempotent:${key}`);
  }

  // For cleanup: scan and remove expired keys
  async cleanup(): Promise<number> {
    const cursor = '0';
    let cleaned = 0;
    const scan = async (c: string): Promise<string> => {
      const [newCursor, keys] = await this.redis.scan(c, 'MATCH', 'idempotent:*', 'COUNT', 100);
      for (const key of keys) {
        const ttl = await this.redis.ttl(key);
        if (ttl === -1) {
          // No TTL set, delete it
          await this.redis.del(key);
          cleaned++;
        }
      }
      return newCursor;
    };

    let currentCursor = cursor;
    do {
      currentCursor = await scan(currentCursor);
    } while (currentCursor !== '0');

    return cleaned;
  }
}

Database-Level Upsert Patterns

For operations that must update state (not just read), use database upserts with unique constraints.

class OrderService {
  async createOrder(req: CreateOrderRequest): Promise<Order> {
    const idempotencyKey = req.idempotencyKey;

    // Upsert with ON CONFLICT
    const result = await this.db.query(
      `INSERT INTO orders (idempotency_key, customer_id, total, status, created_at)
       VALUES ($1, $2, $3, 'pending', NOW())
       ON CONFLICT (idempotency_key)
       DO UPDATE SET updated_at = NOW()
       RETURNING id, customer_id, total, status, created_at`,
      [idempotencyKey, req.customerId, req.totalAmount]
    );

    const order = result.rows[0];

    // If this is a retry, the INSERT will be skipped and we return existing order
    // Ensure the order is not already processed
    if (order.status !== 'pending') {
      return order;
    }

    // Proceed with order processing
    await this.db.query(
      `UPDATE orders SET status = 'processing' WHERE id = $1`,
      [order.id]
    );

    return order;
  }

  async updateOrderStatus(orderId: string, idempotencyKey: string, status: string): Promise<void> {
    const result = await this.db.query(
      `INSERT INTO order_status_changes (order_id, idempotency_key, new_status, changed_at)
       VALUES ($1, $2, $3, NOW())
       ON CONFLICT (order_id, idempotency_key)
       DO NOTHING
       RETURNING id`,
      [orderId, idempotencyKey, status]
    );

    // Only update if this is the first time we've seen this idempotency key
    if (result.rows.length > 0) {
      await this.db.query(`UPDATE orders SET status = $1 WHERE id = $2`, [status, orderId]);
    }
  }
}

Idempotent Webhook Processing

Webhooks are retried on failure. Process them idempotently.

interface WebhookEvent {
  eventId: string; // Unique across all events
  eventType: string;
  timestamp: number;
  data: any;
}

class WebhookProcessor {
  async handleWebhook(event: WebhookEvent): Promise<void> {
    // Use eventId as idempotency key
    const cached = await this.idempotencyStore.get(`webhook:${event.eventId}`);
    if (cached) {
      return; // Already processed
    }

    try {
      await this.processEvent(event);
      await this.idempotencyStore.set(`webhook:${event.eventId}`, { processed: true }, 86400);
    } catch (error) {
      // Don't cache errors; allow retry
      throw error;
    }
  }

  private async processEvent(event: WebhookEvent): Promise<void> {
    switch (event.eventType) {
      case 'payment.completed':
        await this.handlePaymentCompleted(event.data);
        break;
      case 'payment.failed':
        await this.handlePaymentFailed(event.data);
        break;
      default:
        throw new Error(`Unknown event type: ${event.eventType}`);
    }
  }

  private async handlePaymentCompleted(data: any): Promise<void> {
    // Safe to call multiple times
    await this.db.query(
      `UPDATE orders SET status = 'paid', paid_at = NOW()
       WHERE payment_id = $1 AND status != 'paid'`,
      [data.paymentId]
    );
  }

  private async handlePaymentFailed(data: any): Promise<void> {
    await this.db.query(
      `UPDATE orders SET status = 'payment_failed' WHERE payment_id = $1`,
      [data.paymentId]
    );
  }
}

CQRS Command Deduplication

In CQRS, commands are deduplicated at the command handler level.

interface Command {
  commandId: string; // Unique command identifier
  type: string;
  payload: any;
  timestamp: number;
}

class CommandHandler {
  private commandStore = new Map<string, any>(); // or Redis

  async execute<T>(command: Command): Promise<T> {
    // Check if command was already executed
    const result = await this.getCommandResult(command.commandId);
    if (result !== undefined) {
      return result as T;
    }

    // Execute the command
    const commandResult = await this.executeCommand(command);

    // Store result
    await this.storeCommandResult(command.commandId, commandResult);

    return commandResult as T;
  }

  private async executeCommand(command: Command): Promise<any> {
    switch (command.type) {
      case 'CreateAccount':
        return await this.createAccount(command.payload);
      case 'DepositFunds':
        return await this.depositFunds(command.payload);
      case 'WithdrawFunds':
        return await this.withdrawFunds(command.payload);
      default:
        throw new Error(`Unknown command: ${command.type}`);
    }
  }

  private async createAccount(payload: any): Promise<string> {
    const accountId = await this.db.query(
      `INSERT INTO accounts (owner_id, balance, created_at)
       VALUES ($1, $2, NOW())
       RETURNING id`,
      [payload.ownerId, payload.initialBalance]
    );
    return accountId.rows[0].id;
  }

  private async depositFunds(payload: any): Promise<void> {
    await this.db.query(
      `UPDATE accounts SET balance = balance + $1 WHERE id = $2`,
      [payload.amount, payload.accountId]
    );
  }

  private async withdrawFunds(payload: any): Promise<void> {
    const result = await this.db.query(
      `UPDATE accounts SET balance = balance - $1
       WHERE id = $2 AND balance >= $1
       RETURNING balance`,
      [payload.amount, payload.accountId]
    );
    if (result.rows.length === 0) {
      throw new Error('Insufficient funds');
    }
  }

  private async getCommandResult(commandId: string): Promise<any> {
    const result = await this.db.query(
      `SELECT result FROM command_results WHERE command_id = $1`,
      [commandId]
    );
    return result.rows[0]?.result;
  }

  private async storeCommandResult(commandId: string, result: any): Promise<void> {
    await this.db.query(
      `INSERT INTO command_results (command_id, result, executed_at)
       VALUES ($1, $2, NOW())
       ON CONFLICT (command_id) DO NOTHING`,
      [commandId, JSON.stringify(result)]
    );
  }
}

Testing Idempotency with Concurrent Requests

describe('Idempotency', () => {
  it('should deduplicate concurrent identical requests', async () => {
    const service = new PaymentService(store, gateway);
    const idempotencyKey = uuid();

    const promises = Array(5)
      .fill(null)
      .map(() =>
        service.createCharge({
          amount: 1000,
          currency: 'USD',
          customerId: 'cust-123',
          idempotencyKey,
        })
      );

    const results = await Promise.all(promises);

    // All should return same charge ID
    const chargeIds = new Set(results.map(r => r.chargeId));
    expect(chargeIds.size).toBe(1);

    // Only one charge should exist in system
    const charges = await gateway.listCharges({ customerId: 'cust-123' });
    expect(charges.filter(c => c.amount === 1000)).toHaveLength(1);
  });

  it('should return 409 Conflict when retry with same key completes later', async () => {
    const service = new PaymentService(store, gateway);
    const idempotencyKey = uuid();

    const first = service.createCharge({
      amount: 2000,
      currency: 'USD',
      customerId: 'cust-456',
      idempotencyKey,
    });

    const second = service.createCharge({
      amount: 2000,
      currency: 'USD',
      customerId: 'cust-456',
      idempotencyKey,
    });

    const [result1, result2] = await Promise.all([first, second]);
    expect(result1.chargeId).toBe(result2.chargeId);
  });
});

HTTP Status Codes for Duplicates

  • 200 OK: Retry of successful request; return cached response
  • 202 Accepted: Request still processing; advise client to retry
  • 409 Conflict: Different request with same idempotency key; reject with details
class HTTPHandler {
  async post(req: Request): Promise<Response> {
    const idempotencyKey = req.headers['idempotency-key'];
    if (!idempotencyKey) {
      return Response.json({ error: 'Missing idempotency-key' }, { status: 400 });
    }

    const inProgress = await this.store.isInProgress(idempotencyKey);
    if (inProgress) {
      return Response.json({ error: 'Request in progress' }, { status: 202 });
    }

    const existing = await this.store.get(idempotencyKey);
    if (existing) {
      if (existing.requestHash === this.hashRequest(req)) {
        return Response.json(existing.response, { status: 200 });
      } else {
        return Response.json(
          { error: 'Different request with same idempotency key' },
          { status: 409 }
        );
      }
    }

    const response = await this.processRequest(req);
    await this.store.set(idempotencyKey, { response, requestHash: this.hashRequest(req) });
    return Response.json(response, { status: 201 });
  }

  private hashRequest(req: Request): string {
    return crypto
      .createHash('sha256')
      .update(JSON.stringify({ body: req.body }))
      .digest('hex');
  }
}

Checklist

  • Require idempotency keys in all API requests that mutate state
  • Store idempotency results with TTL (24+ hours)
  • Use unique database constraints for insert operations
  • Verify all operations are truly idempotent (no side effects on retry)
  • Test with concurrent retries to catch race conditions
  • Return appropriate HTTP status codes (200, 202, 409)
  • Monitor idempotency cache hit rate
  • Document which endpoints require idempotency keys

Conclusion

Idempotency transforms retries from a liability into a safety net. Design your keys thoughtfully, store results durably, and use database constraints to prevent accidental duplicates. Test concurrency aggressively—the production network will find your assumptions wrong.