Idempotencia en el diseño de APIs: reintentos seguros
Los fallos de red implican reintentos: un diseño idempotente garantiza que procesar dos veces la misma petición no duplique cobros ni pedidos.

El cliente envía una solicitud de pago. El servidor la procesa y envía una respuesta, pero la respuesta se pierde debido a un tiempo de espera de red. El cliente reintenta. Sin idempotencia, el pago se procesa dos veces. Al cliente se le cobra el doble. La idempotencia garantiza que procesar la misma solicitud varias veces tenga el mismo efecto que procesarla una sola vez. En los sistemas distribuidos, donde los fallos de red y los reintentos son inevitables, esto no es un lujo: es un requisito.
Métodos naturalmente idempotentes frente a no idempotentes
Algunos métodos HTTP son naturalmente idempotentes. Otros necesitan un diseño explícito para volverse seguros ante reintentos.
// GET — naturally idempotent (read-only)
app.get("/api/orders/:id", async (req, res) => {
const order = await getOrder(req.params.id);
res.json(order); // Same result no matter how many times you call it
});
// PUT — naturally idempotent (replace entire resource)
app.put("/api/orders/:id", async (req, res) => {
// Replaces the order completely — calling twice produces same state
const order = await replaceOrder(req.params.id, req.body);
res.json(order);
});
// DELETE — naturally idempotent
app.delete("/api/orders/:id", async (req, res) => {
await deleteOrder(req.params.id);
// Second call: order already deleted, same end state
res.status(204).send();
});
// POST — NOT naturally idempotent
app.post("/api/orders", async (req, res) => {
// ❌ Calling twice creates two orders!
const order = await createOrder(req.body);
res.status(201).json(order);
});El patrón de clave de idempotencia
Los clientes incluyen una clave única en cada solicitud. El servidor almacena la clave junto con su resultado; en un reintento, se devuelve el resultado almacenado sin volver a procesar la solicitud.
// Client sends: POST /api/payments
// Header: Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
interface IdempotencyRecord {
key: string;
statusCode: number;
body: unknown;
createdAt: Date;
expiresAt: Date;
}
async function idempotencyMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
const key = req.headers["idempotency-key"] as string;
if (!key) {
// Only require for mutating methods
if (req.method === "POST") {
res.status(400).json({ error: "Idempotency-Key header is required for POST requests" });
return;
}
return next();
}
// Check for existing result
const existing = await db.query<IdempotencyRecord>(
"SELECT * FROM idempotency_keys WHERE key = $1 AND expires_at > NOW()",
[key]
);
if (existing.rows.length > 0) {
const record = existing.rows[0];
res.status(record.statusCode).json(record.body);
return;
}
// Capture the response to store it
const originalJson = res.json.bind(res);
res.json = (body: unknown) => {
// Store the result for future retries
db.query(
`INSERT INTO idempotency_keys (key, status_code, body, created_at, expires_at)
VALUES ($1, $2, $3, NOW(), NOW() + INTERVAL '24 hours')
ON CONFLICT (key) DO NOTHING`,
[key, res.statusCode, JSON.stringify(body)]
);
return originalJson(body);
};
next();
}
app.use("/api/payments", idempotencyMiddleware);Idempotencia a nivel de base de datos
Para operaciones críticas como los pagos, usa restricciones de la base de datos para evitar duplicados incluso si la verificación a nivel de aplicación llega a fallar.
// ❌ Race condition: two concurrent retries both pass the check
async function processPayment(idempotencyKey: string, amount: number) {
const existing = await db.query("SELECT * FROM payments WHERE idempotency_key = $1", [idempotencyKey]);
if (existing.rows.length > 0) return existing.rows[0];
// RACE: Both requests reach here before either inserts
const payment = await chargeCustomer(amount);
await db.query("INSERT INTO payments (idempotency_key, amount) VALUES ($1, $2)", [idempotencyKey, amount]);
return payment;
}
// ✅ Database constraint prevents duplicates
async function processPayment(idempotencyKey: string, amount: number) {
return await db.transaction(async (tx) => {
// Advisory lock on the idempotency key
await tx.query(
"SELECT pg_advisory_xact_lock(hashtext($1))",
[idempotencyKey]
);
const existing = await tx.query(
"SELECT * FROM payments WHERE idempotency_key = $1",
[idempotencyKey]
);
if (existing.rows.length > 0) {
return existing.rows[0]; // Return stored result
}
const payment = await chargeCustomer(amount);
await tx.query(
`INSERT INTO payments (idempotency_key, amount, status, created_at)
VALUES ($1, $2, $3, NOW())`,
[idempotencyKey, amount, payment.status]
);
return payment;
});
}Implementación del lado del cliente
Los clientes son responsables de generar y reutilizar correctamente las claves de idempotencia.
// ❌ Generating a new key on every retry — defeats the purpose
async function createPayment(amount: number): Promise<PaymentResult> {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await fetch("/api/payments", {
method: "POST",
headers: {
"Idempotency-Key": crypto.randomUUID(), // New key each time!
},
body: JSON.stringify({ amount }),
}).then(r => r.json());
} catch {
// Retry with a different key — creates duplicate payments
}
}
throw new Error("Payment failed after 3 attempts");
}
// ✅ Generating the key once and reusing on retries
async function createPayment(amount: number): Promise<PaymentResult> {
const idempotencyKey = crypto.randomUUID(); // Generated once
for (let attempt = 0; attempt < 3; attempt++) {
try {
const response = await fetch("/api/payments", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey, // Same key on every retry
},
body: JSON.stringify({ amount }),
});
if (response.ok) return response.json();
// Don't retry client errors (4xx) — they won't succeed on retry
if (response.status >= 400 && response.status < 500) {
throw new Error(`Client error: ${response.status}`);
}
} catch (error) {
if (attempt === 2) throw error;
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempt) * 1000)
);
}
}
throw new Error("Payment failed after 3 attempts");
}Limpieza y expiración
Los registros de idempotencia deben expirar. Una ventana de 24 horas es habitual: suficiente para cubrir los reintentos, pero lo bastante corta como para evitar un crecimiento ilimitado del almacenamiento.
-- Idempotency key table with automatic expiration
CREATE TABLE idempotency_keys (
key VARCHAR(255) PRIMARY KEY,
status_code INTEGER NOT NULL,
body JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours'
);
-- Index for cleanup queries
CREATE INDEX idx_idempotency_keys_expires ON idempotency_keys (expires_at);
-- Periodic cleanup (run via cron or scheduled job)
DELETE FROM idempotency_keys WHERE expires_at < NOW();Puntos clave
- La idempotencia evita el procesamiento duplicado — esencial para cualquier operación en la que puedan producirse reintentos
- Usa claves de idempotencia en las solicitudes POST — el cliente genera una clave única y el servidor almacena el resultado
- Las restricciones de la base de datos son la última línea de defensa — usa advisory locks o restricciones de unicidad para evitar condiciones de carrera
- Los clientes deben reutilizar la misma clave en los reintentos — generar una clave nueva en cada intento anula por completo el patrón
- Las claves deben expirar — 24 horas suele ser suficiente; limpia periódicamente los registros expirados
- GET, PUT y DELETE son naturalmente idempotentes — concentra el diseño de idempotencia en los endpoints POST y PATCH


