Isaac.

API Idempotency

Design APIs that safely handle duplicate requests.

By EMEPublished: February 20, 2025
idempotencyapi designsafetydeduplication

A Simple Analogy

Idempotent operations are like light switches. Flipping them once or 10 times gives the same result.


Why Idempotency?

  • Network failures: Retry safely without side effects
  • Client confusion: User can retry without worry
  • Consistency: No duplicate charges/records
  • Resilience: Graceful error handling
  • Best practice: Industry standard

Idempotency Key

[HttpPost("orders")]
public async Task<IActionResult> CreateOrder(
    [FromHeader(Name = "Idempotency-Key")] string idempotencyKey,
    CreateOrderRequest request)
{
    if (string.IsNullOrEmpty(idempotencyKey))
        return BadRequest("Idempotency-Key header required");
    
    // Check if we've seen this key before
    var existingOrder = await _db.Orders
        .FirstOrDefaultAsync(o => o.IdempotencyKey == idempotencyKey);
    
    if (existingOrder != null)
        return Ok(existingOrder);  // Return cached response
    
    // Create new order
    var order = new Order
    {
        IdempotencyKey = idempotencyKey,
        CustomerId = request.CustomerId,
        Amount = request.Amount,
        CreatedAt = DateTime.UtcNow
    };
    
    _db.Orders.Add(order);
    await _db.SaveChangesAsync();
    
    return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}

Safe vs Unsafe Methods

GET     - Idempotent (read-only)
HEAD    - Idempotent (read-only)
OPTIONS - Idempotent (read-only)

PUT     - Idempotent (full replacement)
DELETE  - Idempotent (removing same thing twice is safe)

POST    - NOT idempotent (creates new resources)
PATCH   - NOT idempotent (may have side effects)

Client Implementation

async function makeIdempotentRequest(method, url, data) {
  const idempotencyKey = crypto.randomUUID();
  const maxRetries = 3;
  
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(url, {
        method,
        headers: {
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey
        },
        body: JSON.stringify(data)
      });
      
      if (response.ok) {
        return await response.json();
      }
      
      if (response.status >= 500 && i < maxRetries - 1) {
        await new Promise(resolve => 
          setTimeout(resolve, 1000 * Math.pow(2, i))
        );
        continue;
      }
      
      throw new Error(`Request failed: ${response.status}`);
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      await new Promise(resolve => 
        setTimeout(resolve, 1000 * Math.pow(2, i))
      );
    }
  }
}

Storage Considerations

-- Table to track idempotency keys
CREATE TABLE IdempotencyKeys (
    Id INT PRIMARY KEY IDENTITY,
    IdempotencyKey NVARCHAR(MAX) NOT NULL UNIQUE,
    ResourceId INT,
    ResponseStatus INT,
    ResponseBody NVARCHAR(MAX),
    CreatedAt DATETIME DEFAULT GETUTCDATE(),
    
    INDEX IX_Key (IdempotencyKey),
    INDEX IX_CreatedAt (CreatedAt)
);

-- Cleanup old entries (e.g., after 24 hours)
DELETE FROM IdempotencyKeys 
WHERE CreatedAt < DATEADD(HOUR, -24, GETUTCDATE());

Best Practices

  1. Key generation: Use UUID v4 or similar
  2. Storage: Store key with response
  3. Timeout: Delete old entries (24 hours)
  4. Document: Specify which endpoints are idempotent
  5. Communicate: Document idempotency strategy

Related Concepts

  • Retry strategies
  • Circuit breakers
  • Error handling
  • Request deduplication

Summary

Design idempotent APIs using idempotency keys to safely handle retries and duplicate requests.