Topic Overview
Idempotency
Learn how to make operations idempotent to handle retries and failures safely in distributed systems.
Idempotency ensures that performing an operation multiple times has the same effect as performing it once. Critical for handling retries and failures in distributed systems.
What is Idempotency?
An operation is idempotent if:
- Performing it once has the same effect as performing it N times
- Safe to retry without side effects
- Duplicate requests don't cause problems
Examples:
- Idempotent: GET, PUT, DELETE (usually)
- Not idempotent: POST (usually creates new resource)
Why Idempotency Matters
Network failures: Requests can be retried, causing duplicates.
Timeouts: Client doesn't know if request succeeded, retries.
Race conditions: Multiple clients send same request.
At-least-once delivery: Message queues may deliver messages multiple times.
Implementing Idempotency
Idempotency Keys
Client provides unique key, server tracks processed requests.
class IdempotentService {
private processed: Map<string, Response> = new Map();
private ttl: number = 3600000; // 1 hour
async processRequest(request: Request): Promise<Response> {
const idempotencyKey = request.headers['idempotency-key'];
if (!idempotencyKey) {
throw new Error('Idempotency key required');
}
// Check if already processed
const cached = this.processed.get(idempotencyKey);
if (cached) {
return cached; // Return same response
}
// Process request
const response = await this.executeRequest(request);
// Cache response
this.processed.set(idempotencyKey, response);
// Cleanup after TTL
setTimeout(() => {
this.processed.delete(idempotencyKey);
}, this.ttl);
return response;
}
}
Natural Idempotency
Design operations to be naturally idempotent.
// Idempotent: Set value (not increment)
async setBalance(accountId: string, balance: number): Promise<void> {
await db.update('accounts', { balance }, { id: accountId });
}
// Not idempotent: Increment
async incrementBalance(accountId: string, amount: number): Promise<void> {
await db.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amount, accountId]);
}
// Make increment idempotent with idempotency key
async incrementBalanceIdempotent(
accountId: string,
amount: number,
idempotencyKey: string
): Promise<void> {
// Check if this increment was already applied
const applied = await db.query(
'SELECT * FROM applied_operations WHERE idempotency_key = $1',
[idempotencyKey]
);
if (applied.length > 0) {
return; // Already applied
}
// Apply increment
await db.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amount, accountId]);
// Record that operation was applied
await db.query(
'INSERT INTO applied_operations (idempotency_key, account_id, amount) VALUES ($1, $2, $3)',
[idempotencyKey, accountId, amount]
);
}
Examples
Payment Processing
class PaymentService {
async processPayment(
paymentId: string,
amount: number,
idempotencyKey: string
): Promise<Payment> {
// Check if payment already processed
const existing = await this.db.getPaymentByKey(idempotencyKey);
if (existing) {
return existing; // Return same payment
}
// Process payment
const payment = await this.db.createPayment({
id: paymentId,
amount,
idempotencyKey,
status: 'processing'
});
try {
await this.chargeCard(payment);
payment.status = 'completed';
} catch (error) {
payment.status = 'failed';
}
await this.db.updatePayment(payment);
return payment;
}
}
Distributed Lock with Idempotency
class IdempotentLock {
async acquireLock(
resourceId: string,
idempotencyKey: string,
ttl: number
): Promise<boolean> {
// Try to acquire lock with idempotency key
const acquired = await this.redis.set(
`lock:${resourceId}`,
idempotencyKey,
'EX', ttl,
'NX' // Only set if not exists
);
if (acquired) {
return true; // Lock acquired
}
// Check if we already own the lock
const currentOwner = await this.redis.get(`lock:${resourceId}`);
if (currentOwner === idempotencyKey) {
return true; // We already own it (idempotent)
}
return false; // Locked by someone else
}
}
Common Pitfalls
- Not using idempotency keys: Relying on natural idempotency only works for some operations
- Short TTL: Idempotency keys expire too soon, allowing duplicates. Fix: Use appropriate TTL based on operation
- Not storing response: Must return same response for same key
- Race conditions: Multiple requests with same key processed concurrently. Fix: Use database constraints or locks
- Not cleaning up: Idempotency keys accumulate. Fix: Use TTL, periodic cleanup
- Key collision: Different operations use same key. Fix: Include operation type in key
Interview Questions
Beginner
Q: What is idempotency and why is it important?
A: Idempotency means performing an operation multiple times has the same effect as performing it once.
Why important:
- Retries: Network failures cause retries, must handle safely
- Timeouts: Client doesn't know if request succeeded, retries
- Duplicates: Message queues may deliver messages multiple times
- Race conditions: Multiple clients send same request
Example: Payment processing - charging a card twice for the same order would be bad. Idempotency ensures duplicate requests don't cause duplicate charges.
Intermediate
Q: How do you implement idempotency for a payment API? What are the challenges?
A:
Implementation:
async processPayment(idempotencyKey: string, amount: number): Promise<Payment> {
// Check if already processed
const existing = await db.getPaymentByKey(idempotencyKey);
if (existing) {
return existing; // Return same payment (idempotent)
}
// Process payment
const payment = await this.chargeCard(amount, idempotencyKey);
await db.savePayment(payment);
return payment;
}
Challenges:
-
Race conditions: Two requests with same key arrive simultaneously
- Fix: Use database unique constraint on idempotency key
- Or use distributed lock
-
Storage: Must store idempotency keys and responses
- Fix: Use Redis with TTL, or database table
-
TTL: How long to keep idempotency keys?
- Fix: Based on operation (payments: days/weeks, API calls: hours)
-
Key generation: Client must generate unique keys
- Fix: Use UUIDs, or client ID + timestamp + operation
Senior
Q: Design an idempotent distributed system for processing orders. Orders involve inventory, payment, and shipping. How do you ensure idempotency across all services and handle partial failures?
A:
Architecture:
- Idempotency keys: Each order has unique idempotency key
- Distributed idempotency: Each service checks key before processing
- Saga with idempotency: Each saga step is idempotent
Design:
class IdempotentOrderSystem {
async processOrder(orderId: string, idempotencyKey: string): Promise<void> {
// Check if order already processed
const existing = await this.getOrder(orderId);
if (existing && existing.status === 'completed') {
return; // Already processed
}
// Process with idempotent saga
await this.idempotentSaga.execute(orderId, idempotencyKey);
}
}
class IdempotentSaga {
async execute(orderId: string, idempotencyKey: string): Promise<void> {
const steps = [
{
name: 'reserve-inventory',
execute: () => this.reserveInventory(orderId, idempotencyKey),
compensate: () => this.releaseInventory(orderId, idempotencyKey)
},
{
name: 'charge-payment',
execute: () => this.chargePayment(orderId, idempotencyKey),
compensate: () => this.refundPayment(orderId, idempotencyKey)
},
{
name: 'create-shipment',
execute: () => this.createShipment(orderId, idempotencyKey),
compensate: () => this.cancelShipment(orderId, idempotencyKey)
}
];
for (const step of steps) {
await this.executeStepIdempotently(step, orderId, idempotencyKey);
}
}
async executeStepIdempotently(
step: Step,
orderId: string,
idempotencyKey: string
): Promise<void> {
// Create step-specific idempotency key
const stepKey = `${idempotencyKey}:${step.name}`;
// Check if step already executed
const state = await this.getStepState(orderId, step.name);
if (state === 'completed') {
return; // Already done
}
try {
await step.execute();
await this.saveStepState(orderId, step.name, 'completed', stepKey);
} catch (error) {
await this.saveStepState(orderId, step.name, 'failed', stepKey);
throw error;
}
}
}
// Each service implements idempotency
class InventoryService {
async reserveInventory(
orderId: string,
idempotencyKey: string
): Promise<void> {
const stepKey = `${idempotencyKey}:reserve-inventory`;
// Check if already reserved
const reservation = await db.getReservation(stepKey);
if (reservation) {
return; // Already reserved (idempotent)
}
// Reserve inventory
await db.reserveInventory(orderId, stepKey);
}
}
Handling Partial Failures:
- Checkpoint state: Store state after each step
- Resume on retry: Check state, resume from last completed step
- Idempotent compensation: Compensations must also be idempotent
Distributed Idempotency:
- Shared storage: Use Redis or database for idempotency keys
- Consistent hashing: Route same key to same node for caching
- Replication: Replicate idempotency state for availability
Key Takeaways
- Idempotency is critical for handling retries and failures safely
- Idempotency keys: Client provides unique key, server tracks processed requests
- Natural idempotency: Design operations to be idempotent (SET not INCREMENT)
- Store responses: Must return same response for same idempotency key
- Handle race conditions: Use database constraints or locks
- TTL management: Clean up old idempotency keys appropriately
- Distributed systems: Each service must check idempotency independently
- Saga steps: Each step in a saga should be idempotent