The Problem
Noah leads backend at a 20-person fintech processing payments. A customer's mobile app retries a $500 payment request due to a network timeout — the payment processes twice. They lost $12K last month from duplicate charges. Webhook deliveries retry on timeouts, creating duplicate events. Their API has no way to distinguish a retry from a new request. Manual refunds take 3 business days. They need idempotency: clients send a unique key with each request; if the key was seen before, return the original response without re-executing; handle concurrent duplicate requests safely.
Step 1: Build the Idempotency Layer
typescript
// src/idempotency/layer.ts — Idempotent API requests with response caching and dedup
import { Redis } from "ioredis";
import { createHash } from "node:crypto";
const redis = new Redis(process.env.REDIS_URL!);
interface IdempotentResponse {
status: number;
headers: Record<string, string>;
body: string;
completedAt: string;
}
const DEFAULT_TTL = 86400; // 24 hours
// Middleware: enforce idempotency on mutating requests
export function idempotencyMiddleware(options?: { ttl?: number; requiredOn?: string[] }) {
return async (c: any, next: any) => {
// Only apply to mutating methods
if (["GET", "HEAD", "OPTIONS"].includes(c.req.method)) return next();
const idempotencyKey = c.req.header("Idempotency-Key");
// Require key on specified endpoints
if (options?.requiredOn?.some((p) => c.req.path.startsWith(p)) && !idempotencyKey) {
return c.json({ error: "Idempotency-Key header required" }, 400);
}
if (!idempotencyKey) return next(); // no key = no idempotency
const cacheKey = `idem:${idempotencyKey}`;
const lockKey = `idem:lock:${idempotencyKey}`;
// Check if we already have a response for this key
const cached = await redis.get(cacheKey);
if (cached) {
const response: IdempotentResponse = JSON.parse(cached);
c.header("Idempotent-Replayed", "true");
return c.json(JSON.parse(response.body), response.status);
}
// Check if another request with same key is currently processing
const acquired = await redis.set(lockKey, "processing", "EX", 30, "NX");
if (!acquired) {
// Wait for the first request to complete
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 500));
const result = await redis.get(cacheKey);
if (result) {
const response: IdempotentResponse = JSON.parse(result);
c.header("Idempotent-Replayed", "true");
return c.json(JSON.parse(response.body), response.status);
}
}
return c.json({ error: "Request still processing" }, 409);
}
try {
// Execute the actual handler
await next();
// Cache the response
const body = await c.res.clone().text();
const ttl = options?.ttl || DEFAULT_TTL;
const idempotentResponse: IdempotentResponse = {
status: c.res.status,
headers: Object.fromEntries(c.res.headers.entries()),
body,
completedAt: new Date().toISOString(),
};
await redis.setex(cacheKey, ttl, JSON.stringify(idempotentResponse));
} finally {
await redis.del(lockKey);
}
};
}
// Generate fingerprint from request body (auto-idempotency for identical requests)
export function generateFingerprint(method: string, path: string, body: any): string {
const payload = `${method}:${path}:${JSON.stringify(body)}`;
return createHash("sha256").update(payload).digest("hex").slice(0, 32);
}
// Check if key was already used (for client-side validation)
export async function checkKey(key: string): Promise<{ exists: boolean; response?: IdempotentResponse }> {
const cached = await redis.get(`idem:${key}`);
if (cached) return { exists: true, response: JSON.parse(cached) };
return { exists: false };
}
// Cleanup expired keys (optional, Redis TTL handles this)
export async function getIdempotencyStats(): Promise<{
totalKeys: number; replayedRequests: number;
}> {
const keys = await redis.keys("idem:*");
const stats = await redis.hgetall("idem:stats");
return {
totalKeys: keys.filter((k) => !k.includes("lock") && !k.includes("stats")).length,
replayedRequests: parseInt(stats.replayed || "0"),
};
}
Results
- Duplicate payments: $12K/month → $0 — retry sends same Idempotency-Key; server returns cached response; payment processes exactly once
- Concurrent dedup — two identical requests arrive 50ms apart; first acquires lock, second waits; both get the same response; no race condition
- 24-hour replay window — client can retry anytime within 24 hours; gets original response; covers long network outages and mobile app retries
- Webhook safety — webhook deliveries include idempotency key; consumer processes event once; retries return cached acknowledgment
- Zero client changes for existing endpoints — middleware applies globally; clients add one header; endpoints don't need modification