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
- Key generation: Use UUID v4 or similar
- Storage: Store key with response
- Timeout: Delete old entries (24 hours)
- Document: Specify which endpoints are idempotent
- 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.