Saltar al contenido

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.

4 min de lectura
Diagrama de secuencia que muestra cómo una solicitud idempotente se reintenta de forma segura tras un tiempo de espera agotado

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.

tstypescript
// 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.

tstypescript
// 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.

tstypescript
// ❌ 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.

tstypescript
// ❌ 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.

sqlsql
-- 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

  1. La idempotencia evita el procesamiento duplicado — esencial para cualquier operación en la que puedan producirse reintentos
  2. Usa claves de idempotencia en las solicitudes POST — el cliente genera una clave única y el servidor almacena el resultado
  3. 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
  4. Los clientes deben reutilizar la misma clave en los reintentos — generar una clave nueva en cada intento anula por completo el patrón
  5. Las claves deben expirar — 24 horas suele ser suficiente; limpia periódicamente los registros expirados
  6. GET, PUT y DELETE son naturalmente idempotentes — concentra el diseño de idempotencia en los endpoints POST y PATCH
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX