Designing for Idempotency - Building Safe APIs and Operations

12 min read | 2024.12.28

What is Idempotency

Idempotency is the property where executing the same operation multiple times produces the same result. It is an important concept for ensuring reliability in distributed systems and API design.

Mathematical Definition: f(f(x)) = f(x) Applying the same function multiple times yields the same result as applying it once.

Why Idempotency Matters

In network communication, the following problems can occur:

sequenceDiagram
    participant C as Client
    participant S as Server
    C->>S: Request
    Note over C,S: Timeout (Response didn't arrive)
    Note over C: Question: Was the request processed?<br/>Should we retry?

If an operation is idempotent, it can be safely retried.

HTTP Methods and Idempotency

MethodIdempotentDescription
GETResource retrieval, no side effects
HEADSame as GET (no body)
PUTComplete replacement of resource
DELETEResource deletion
POSTResource creation, has side effects
PATCHPartial update (depends on implementation)

PUT vs POST

MethodResult
PUT /users/123No matter how many times executed, user 123 is in the same state
POST /usersA new user might be created each time it’s executed

Idempotency Key

A technique to achieve idempotency even with POST requests.

POST /payments
Idempotency-Key: pay_abc123xyz
Content-Type: application/json

{
  "amount": 5000,
  "currency": "JPY"
}

Implementation Example

async function processPayment(req, res) {
  const idempotencyKey = req.headers['idempotency-key'];

  if (!idempotencyKey) {
    return res.status(400).json({ error: 'Idempotency-Key required' });
  }

  // Check for existing processing result
  const existing = await redis.get(`idempotency:${idempotencyKey}`);
  if (existing) {
    return res.status(200).json(JSON.parse(existing));
  }

  // Acquire lock (prevent concurrent execution)
  const lock = await acquireLock(idempotencyKey);
  if (!lock) {
    return res.status(409).json({ error: 'Request in progress' });
  }

  try {
    // Execute processing
    const result = await executePayment(req.body);

    // Save result (valid for 24 hours)
    await redis.setex(
      `idempotency:${idempotencyKey}`,
      86400,
      JSON.stringify(result)
    );

    return res.status(201).json(result);
  } finally {
    await releaseLock(idempotencyKey);
  }
}

Idempotency Implementation Patterns

1. Duplicate Check with Unique Identifier

async function createOrder(orderData, requestId) {
  // Check for existing
  const existing = await db.orders.findByRequestId(requestId);
  if (existing) {
    return existing; // Return same result
  }

  // Create new
  const order = await db.orders.create({
    ...orderData,
    requestId // Save unique identifier
  });

  return order;
}

2. State Check

async function cancelOrder(orderId) {
  const order = await db.orders.findById(orderId);

  // Do nothing if already cancelled
  if (order.status === 'cancelled') {
    return order; // Idempotent
  }

  // Check if cancellation is possible
  if (order.status === 'shipped') {
    throw new Error('Cannot cancel shipped order');
  }

  return await db.orders.update(orderId, { status: 'cancelled' });
}

3. Optimistic Locking

async function updateInventory(productId, quantity, version) {
  const result = await db.query(
    `UPDATE inventory
     SET quantity = quantity - $1, version = version + 1
     WHERE product_id = $2 AND version = $3`,
    [quantity, productId, version]
  );

  if (result.rowCount === 0) {
    throw new Error('Concurrent modification detected');
  }
}

Making Non-Idempotent Operations Idempotent

Increment Operations

// Non-idempotent
UPDATE balance SET amount = amount + 100 WHERE user_id = 123

// Make it idempotent (set absolute value)
UPDATE balance SET amount = 5100 WHERE user_id = 123

// Or, manage with transaction ID
INSERT INTO transactions (id, user_id, amount)
VALUES ('tx_abc', 123, 100)
ON CONFLICT (id) DO NOTHING;

UPDATE balance
SET amount = (SELECT SUM(amount) FROM transactions WHERE user_id = 123)
WHERE user_id = 123;

Email Sending

async function sendWelcomeEmail(userId, requestId) {
  // Check if already sent
  const sent = await db.emailLogs.findOne({
    userId,
    type: 'welcome',
    requestId
  });

  if (sent) {
    return { status: 'already_sent' };
  }

  // Send
  await emailService.send(/* ... */);

  // Record log
  await db.emailLogs.create({
    userId,
    type: 'welcome',
    requestId,
    sentAt: new Date()
  });

  return { status: 'sent' };
}

Retry Strategies

Exponential Backoff

async function retryWithBackoff(fn, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (error) {
      if (i === maxRetries - 1) throw error;

      // Exponential backoff + jitter
      const delay = Math.pow(2, i) * 1000 + Math.random() * 1000;
      await sleep(delay);
    }
  }
}

Errors to Retry

RetriableNon-retriable
5xx errors (server errors)4xx errors (client errors)
TimeoutsBusiness logic errors
Temporary network errorsAuthentication errors

Summary

Idempotency is an important design principle for ensuring reliability in distributed systems and APIs. Use patterns like idempotency keys, state checks, and optimistic locking to design operations that can be safely retried.

← Back to list