omicity and body validation.
import { Request, Response, NextFunction } from 'express';
import { createHash } from 'crypto';
// Store interface for pluggable backends (Redis, DynamoDB, etc.)
export interface IdempotencyStore {
getRecord(key: string): Promise<IdempotencyRecord | null>;
setProcessing(key: string, bodyHash: string): Promise<boolean>;
setCompleted(key: string, record: IdempotencyRecord): Promise<void>;
}
export interface IdempotencyRecord {
status: 'processing' | 'completed' | 'failed';
statusCode: number;
body: any;
headers?: Record<string, string>;
bodyHash: string;
}
// Middleware factory
export function idempotencyMiddleware(store: IdempotencyStore) {
return async (req: Request, res: Response, next: NextFunction) => {
const key = req.headers['x-idempotency-key'] as string;
// Idempotency only applies to mutating methods with a key
if (!key || !['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
return next();
}
// Compute body hash for validation
const bodyHash = createHash('sha256').update(JSON.stringify(req.body)).digest('hex');
try {
// 1. Check for existing record
const existing = await store.getRecord(key);
if (existing) {
if (existing.status === 'processing') {
// Race condition: Another request is processing this key
return res.status(409).json({
error: 'Request is currently being processed',
retry_after: 2
});
}
// 2. Validate body hash on retry
if (existing.bodyHash !== bodyHash) {
return res.status(422).json({
error: 'Idempotency key reused with different request body'
});
}
// 3. Return cached response
if (existing.headers) {
res.set(existing.headers);
}
return res.status(existing.statusCode).json(existing.body);
}
// 4. Atomically mark as processing
const acquired = await store.setProcessing(key, bodyHash);
if (!acquired) {
return res.status(409).json({ error: 'Concurrent request detected' });
}
// 5. Intercept response to cache result
const originalJson = res.json.bind(res);
res.json = (body: any) => {
const record: IdempotencyRecord = {
status: res.statusCode >= 400 ? 'failed' : 'completed',
statusCode: res.statusCode,
body,
headers: res.getHeaders() as Record<string, string>,
bodyHash
};
// Fire-and-forget cache update
store.setCompleted(key, record).catch(console.error);
return originalJson(body);
};
next();
} catch (err) {
console.error('Idempotency store error:', err);
next(err);
}
};
}
Redis Backend Implementation
For production, use Redis with Lua scripting to ensure atomicity.
import { Redis } from 'ioredis';
export class RedisIdempotencyStore implements IdempotencyStore {
private client: Redis;
private ttlSeconds: number;
constructor(redisClient: Redis, ttlSeconds = 86400) {
this.client = redisClient;
this.ttlSeconds = ttlSeconds;
}
async getRecord(key: string): Promise<IdempotencyRecord | null> {
const data = await this.client.get(`idem:${key}`);
return data ? JSON.parse(data) : null;
}
async setProcessing(key: string, bodyHash: string): Promise<boolean> {
// Atomic check-and-set using Redis SET NX
const result = await this.client.set(
`idem:${key}`,
JSON.stringify({ status: 'processing', bodyHash }),
'EX',
this.ttlSeconds,
'NX'
);
return result === 'OK';
}
async setCompleted(key: string, record: IdempotencyRecord): Promise<void> {
await this.client.set(
`idem:${key}`,
JSON.stringify(record),
'EX',
this.ttlSeconds
);
}
}
Client Implementation (TypeScript)
The client must generate the key once per logical operation and persist it before the first attempt. This ensures that if the process crashes mid-retry, the same key can be recovered.
import { v4 as uuidv4 } from 'uuid';
interface RetryConfig {
maxAttempts: number;
baseDelay: number;
}
export async function safePost<T>(
url: string,
payload: any,
config: RetryConfig = { maxAttempts: 3, baseDelay: 1000 }
): Promise<T> {
// Generate key once per logical operation
const idempotencyKey = uuidv4();
// Persist key to local storage or DB before network call
// await persistOperation(idempotencyKey, payload);
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(payload),
});
if (response.status === 409) {
// Server is still processing; backoff and retry
const retryAfter = parseInt(response.headers.get('Retry-After') || '2', 10);
await sleep(retryAfter * 1000);
continue;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
} catch (err) {
if (attempt === config.maxAttempts) throw err;
// Exponential backoff for network errors
const delay = config.baseDelay * Math.pow(2, attempt - 1);
await sleep(delay);
}
}
throw new Error('Max retries exceeded');
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
Pitfall Guide
-
Non-Atomic Check-and-Set
- Explanation: If the middleware checks for the key and then sets it in two separate steps, concurrent requests can both pass the check and execute the operation.
- Fix: Use atomic operations like Redis
SET NX or Lua scripts to ensure only one request can claim the key.
-
Body Mutation on Retry
- Explanation: A client retries with the same key but modifies the payload (e.g., changing the amount). The server processes the new payload, violating idempotency.
- Fix: Hash the request body on the first attempt and compare it on subsequent retries. Return
422 Unprocessable Entity if hashes mismatch.
-
The 409 Retry Loop
- Explanation: The client receives a
409 Conflict and immediately retries, hitting the server again while it's still processing. This creates a tight loop that wastes resources.
- Fix: Clients must implement exponential backoff when receiving
409. Servers should include a Retry-After header to guide the client.
-
Response Bloat
- Explanation: Caching large responses (e.g., megabytes of data) in the idempotency store increases memory usage and latency.
- Fix: Limit cached response size. For large payloads, store a reference or compressed version, or configure the store to evict large entries aggressively.
-
TTL Mismatch with Async Flows
- Explanation: An asynchronous job takes 48 hours to complete, but the idempotency key expires after 24 hours. A retry after 30 hours creates a duplicate operation.
- Fix: Set TTL based on the maximum service level agreement (SLA) for the operation. For async jobs, use longer TTLs (e.g., 7 days) or implement a "key extension" mechanism.
-
Ignoring Error Responses
- Explanation: The server caches only successful responses. If a request fails with a
422, the client retries, and the server re-executes the logic, potentially causing side effects.
- Fix: Cache all responses, including client and server errors. This ensures that a failing request remains failing on retry without re-execution.
-
Key Scoping Errors
- Explanation: Reusing the same idempotency key across different endpoints or resource types.
- Fix: Ensure keys are unique per logical operation. The client should generate a fresh key for each distinct action, even if the payload is similar.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Payment Processing | Idempotency Keys | Financial safety requires exactly-once semantics. | Low (Redis storage) |
| Read-Only Queries | None | GET requests are naturally idempotent. | None |
| Fire-and-Forget Events | Async Job ID | No response needed; use event ID for deduplication at sink. | Medium |
| High-Throughput Logging | None | Duplicates are often acceptable or handled downstream. | None |
| Async Provisioning | Idempotency Keys + Long TTL | Prevents duplicate resource creation during long delays. | Low |
Configuration Template
Redis configuration for idempotency store with appropriate TTL and eviction policies.
# docker-compose.yml snippet for Redis
services:
idempotency-store:
image: redis:7-alpine
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
redis_data:
Quick Start Guide
- Add Header: Include
X-Idempotency-Key in all mutating API requests. Generate a UUIDv4 per operation.
- Install Store: Set up a Redis instance and configure the
RedisIdempotencyStore in your server.
- Apply Middleware: Wrap critical routes with
idempotencyMiddleware(store).
- Test Replay: Use a tool like
curl to send the same request twice with the same key. Verify the second response matches the first and no side effects occur.
- Monitor: Track
409 responses and cache hit rates to ensure the system is functioning correctly.