e Execution Contract
The header name should follow the de facto standard: Idempotency-Key. The key must represent a logical business operation, not a network request. TTL should align with your longest expected retry window (typically 24-48 hours). Only mutating HTTP methods require enforcement.
Step 2: Server-Side Guard Implementation
import { Request, Response, NextFunction } from 'express';
import Redis from 'ioredis';
import { createHash } from 'crypto';
interface CachedExecution {
status: number;
payload: unknown;
bodyDigest: string;
}
export class MutationGuard {
private readonly cache: Redis;
private readonly ttlSeconds: number;
private readonly mutationMethods: Set<string>;
constructor(redisClient: Redis, ttlHours: number = 24) {
this.cache = redisClient;
this.ttlSeconds = ttlHours * 3600;
this.mutationMethods = new Set(['POST', 'PATCH', 'PUT', 'DELETE']);
}
public middleware(req: Request, res: Response, next: NextFunction): void {
const headerKey = req.headers['idempotency-key'];
// Skip safe methods and requests without the header
if (!this.mutationMethods.has(req.method) || !headerKey) {
return next();
}
const cacheKey = `exec:${headerKey}`;
const requestDigest = this.computeDigest(req.body);
// Atomic check-and-execute flow
this.cache.get(cacheKey).then((raw) => {
if (raw) {
const cached: CachedExecution = JSON.parse(raw);
// Detect payload drift: same key, different business intent
if (cached.bodyDigest !== requestDigest) {
return res.status(422).json({
code: 'payload_drift_detected',
message: 'Idempotency key reused with a different request body.'
});
}
// Return cached outcome
return res.status(cached.status).json(cached.payload);
}
// First execution: intercept response to cache it
this.interceptResponse(res, cacheKey, requestDigest);
next();
}).catch(next);
}
private interceptResponse(res: Response, cacheKey: string, digest: string): void {
const originalSend = res.send.bind(res);
res.send = function (body: any): Response {
// Only cache successful or client-error responses
if (res.statusCode >= 200 && res.statusCode < 500) {
const record: CachedExecution = {
status: res.statusCode,
payload: body,
bodyDigest: digest
};
// Fire-and-forget cache write; don't block response
this.cache.setex(
cacheKey,
this.ttlSeconds,
JSON.stringify(record)
).catch(() => { /* Log warning, don't fail request */ });
}
return originalSend(body);
}.bind(this);
}
private computeDigest(payload: unknown): string {
return createHash('sha256')
.update(JSON.stringify(payload, Object.keys(payload as object).sort()))
.digest('hex');
}
}
Step 3: Client-Side Key Derivation
Random UUIDs fail in retry scenarios because the client loses the key after a crash. Deterministic derivation ensures the same logical operation always produces the same token.
import { createHash } from 'crypto';
export function deriveExecutionToken(
tenantId: string,
operation: string,
targetId: string,
version?: number
): string {
const payload = [tenantId, operation, targetId, version ?? 0].join(':');
return createHash('sha256').update(payload).digest('hex').slice(0, 40);
}
// Usage in HTTP client
const token = deriveExecutionToken('acct_9f2a', 'create_invoice', 'order_7742', 1);
await fetch('https://api.billing.internal/v1/invoices', {
method: 'POST',
headers: {
'Idempotency-Key': token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ amount: 4999, currency: 'usd' })
});
Architecture Rationale
- Class-based middleware: Enables dependency injection, simplifies unit testing, and isolates Redis connection lifecycle.
- Sorted JSON keys in digest: Prevents false payload drift when clients serialize objects in different orders.
- Response interception on
res.send: Catches all response types (JSON, text, buffers) rather than limiting to res.json.
- 5xx exclusion: Server crashes or timeouts are transient. Caching them would permanently block legitimate retries.
- TTL alignment: 24 hours covers standard payment gateway retry windows and mobile app background sync cycles without unbounded storage growth.
Pitfall Guide
1. Caching Server Errors (5xx)
Explanation: Storing 500/502/503 responses locks clients into a permanent failure state. The original request may have failed due to a momentary database lock or network partition that resolves on retry.
Fix: Only cache responses with status codes in the 200-499 range. Let 5xx errors bypass the cache entirely.
2. Using Random UUIDs for Keys
Explanation: crypto.randomUUID() generates a new token on every call. If the client crashes after sending the request but before receiving the response, it cannot reconstruct the key for retry, defeating the purpose.
Fix: Derive keys deterministically from business context (tenant ID, operation type, resource ID, version counter).
3. Ignoring Payload Drift
Explanation: A client bug or malicious actor may reuse an idempotency key with a different request body. Without validation, the server returns the old response, causing silent data corruption.
Fix: Compute a SHA-256 digest of the request body on first execution. Compare it on subsequent calls. Return 422 Unprocessable Entity if digests mismatch.
4. Race Conditions on First Execution
Explanation: Two identical requests arrive simultaneously before the cache is populated. Both pass the GET check, execute business logic, and cache results. The operation runs twice.
Fix: Use Redis SETNX with a Lua script or WATCH/MULTI transaction to atomically check-and-set. Alternatively, accept the race window if downstream operations are idempotent by design, but document the trade-off.
5. Overly Aggressive or Passive TTL
Explanation: A 1-hour TTL expires before a mobile app retries after airplane mode. A 30-day TTL bloats Redis memory and violates data retention policies.
Fix: Align TTL with your longest expected retry SLA. Financial systems typically use 24-48 hours. Configure automated monitoring for cache hit rates and storage growth.
6. Applying Guards to Safe Methods
Explanation: GET, HEAD, and OPTIONS requests are defined as safe and idempotent by HTTP specification. Adding overhead to them wastes CPU and cache space.
Fix: Restrict middleware to POST, PATCH, PUT, and DELETE. Explicitly whitelist safe methods in configuration.
Explanation: Clients may forget to send the header, or load balancers may strip it. The guard silently bypasses protection, creating inconsistent behavior.
Fix: For critical mutation endpoints, enforce header presence. Return 400 Bad Request if Idempotency-Key is absent on mutating methods. Document this requirement in OpenAPI specs.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Storage | Why | Cost Impact |
|---|
| High-throughput payment API (>10k req/s) | Redis Cluster with Lua atomicity | Sub-millisecond lookups, horizontal scaling, race-condition safety | High infrastructure cost, low per-request latency |
| Internal B2B SaaS (<500 req/s) | Single-node Redis or Memcached | Simpler ops, sufficient for moderate load, easy debugging | Low infrastructure cost, acceptable latency |
| Compliance/Audit requirements (PCI-DSS, SOC2) | Redis + PostgreSQL audit log | Cache for performance, DB for immutable execution trail | Medium infrastructure cost, high compliance value |
| Serverless/Edge deployments | Durable Objects (Cloudflare) or DynamoDB | Stateful execution without external cache dependency | Pay-per-request pricing, higher latency than Redis |
Configuration Template
// idempotency.config.ts
import Redis from 'ioredis';
import { MutationGuard } from './middleware/mutation-guard';
export const redisClient = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD,
maxRetriesPerRequest: 3,
retryStrategy: (times) => Math.min(times * 50, 2000)
});
export const idempotencyGuard = new MutationGuard(redisClient, 24);
// Express integration
import express from 'express';
const app = express();
app.use(express.json());
// Apply guard globally or per-route
app.use('/api/v1/billing', idempotencyGuard.middleware);
// OpenAPI documentation snippet
/**
* @openapi
* /api/v1/billing/charges:
* post:
* summary: Create a payment charge
* parameters:
* - in: header
* name: Idempotency-Key
* required: true
* schema: { type: string }
* description: Deterministic token for exactly-once execution
*/
Quick Start Guide
- Install dependencies:
npm install ioredis express @types/express
- Create the guard class: Copy the
MutationGuard implementation into your middleware directory.
- Register middleware: Attach
idempotencyGuard.middleware to your Express/Fastify app before route handlers.
- Update client SDK: Replace random UUID generation with
deriveExecutionToken() using business context.
- Verify behavior: Send identical POST requests twice. Confirm the second returns the cached response with identical status and payload, and that payload drift triggers a
422.