Saltar al contenido

Claves de idempotencia en la práctica: reintentos seguros

Cómo diseñar e implementar claves de idempotencia para que las solicitudes reintentadas nunca cobren, envíen o creen recursos por duplicado.

6 min de lectura
Diagrama de secuencia que muestra a un cliente reintentando una solicitud con una clave de idempotencia y recibiendo una respuesta almacenada en caché

Las redes fallan en mitad de las solicitudes todo el tiempo. El cliente envía una solicitud de pago, el servidor la procesa, cobra la tarjeta, y luego la respuesta nunca llega de vuelta: tiempo de espera agotado, conexión interrumpida, lo que sea. El cliente, al no recibir respuesta, reintenta. Ahora le has cobrado al cliente dos veces.

Esto no es un caso extremo hipotético. Es el comportamiento por defecto de cualquier mecanismo de reintentos que hayas publicado, a menos que lo hayas diseñado explícitamente para evitarlo. Las claves de idempotencia son la solución estándar, pero la mayoría de las implementaciones que he visto se equivocan en los detalles: guardan la clave sin guardar la respuesta, no manejan reintentos concurrentes, o dejan que la clave colisione entre distintos cuerpos de solicitud. Vamos a corregir eso.

Por qué "Es un POST, así que no es idempotente" es un planteamiento equivocado

La semántica de REST dice que GET, PUT y DELETE son idempotentes por convención, y POST no lo es. Eso es una afirmación sobre la semántica de los métodos HTTP, no sobre lo que tu negocio realmente necesita. Crear un pedido es un POST, pero si el cliente reintenta con la misma intención, quieres el mismo pedido, no un pedido duplicado.

La solución es desacoplar la idempotencia del método HTTP y ponerla bajo el control de quien llama: el cliente genera una clave única por operación lógica y la envía junto con la solicitud. El servidor usa esa clave para detectar duplicados, sin importar el verbo.

httphttp
POST /api/payments HTTP/1.1
Idempotency-Key: 7c3a9e02-4b1d-4f8e-9c21-6a1e8f0d5b3a
Content-Type: application/json
 
{"amount": 4999, "currency": "usd", "customerId": "cus_123"}

Guardar la clave no es suficiente — también hay que guardar la respuesta

Un error común: comprobar si la clave existe y, si es así, devolver simplemente un mensaje genérico de "ya procesado". Eso no le sirve de nada al cliente: necesita el resultado real de la solicitud original, sobre todo si esta incluía un ID de recurso generado.

tstypescript
// ❌ Tells the client nothing useful on retry
async function createPayment(req: PaymentRequest, key: string) {
  const existing = await db.idempotencyKeys.findOne({ key });
  if (existing) {
    return { status: 409, body: { error: "duplicate request" } };
  }
  const payment = await chargeCard(req);
  await db.idempotencyKeys.insertOne({ key, createdAt: new Date() });
  return { status: 201, body: payment };
}
 
// ✅ Returns the original response, verbatim, on retry
async function createPayment(req: PaymentRequest, key: string) {
  const existing = await db.idempotencyKeys.findOne({ key });
  if (existing) {
    return { status: existing.statusCode, body: existing.responseBody };
  }
 
  const payment = await chargeCard(req);
 
  await db.idempotencyKeys.insertOne({
    key,
    statusCode: 201,
    responseBody: payment,
    requestHash: hashRequest(req),
    createdAt: new Date(),
  });
 
  return { status: 201, body: payment };
}

El cliente debería poder reintentar a ciegas y recibir exactamente lo mismo que habría recibido la primera vez: el mismo ID de pedido, el mismo importe, el mismo estado.

Protégete de la reutilización de claves con distintos cuerpos de solicitud

Si un cliente envía la misma Idempotency-Key con un cuerpo de solicitud distinto, eso es un error de su lado (o una colisión generada por el cliente), y deberías rechazarlo de forma explícita en lugar de devolver silenciosamente la primera respuesta para una solicitud diferente.

tstypescript
function hashRequest(req: PaymentRequest): string {
  return crypto
    .createHash("sha256")
    .update(JSON.stringify(sortKeys(req)))
    .digest("hex");
}
 
async function createPayment(req: PaymentRequest, key: string) {
  const requestHash = hashRequest(req);
  const existing = await db.idempotencyKeys.findOne({ key });
 
  if (existing) {
    if (existing.requestHash !== requestHash) {
      return {
        status: 422,
        body: { error: "idempotency key reused with a different payload" },
      };
    }
    return { status: existing.statusCode, body: existing.responseBody };
  }
 
  // proceed with the actual operation...
}
!

Nunca confíes en que el cliente reutilizará las claves correctamente. Aplica un hash y compara el cuerpo de la solicitud cada vez: es una comprobación barata que detecta toda una clase de errores reales.

La condición de carrera que todos olvidan

Dos solicitudes idénticas pueden llegar casi al mismo tiempo: un cliente reintentando de forma agresiva, o un proxy duplicando una solicitud. Si tu lógica de "comprobar y luego insertar" no es atómica, ambas solicitudes pueden pasar la comprobación de existencia antes de que ninguna haya escrito la clave, y terminas procesando la operación dos veces de todos modos.

La solución es hacer que la inserción de la clave sea atómica y usarla como un bloqueo, no solo como una tabla de consulta.

tstypescript
// ❌ Race condition: two concurrent requests both pass the check
const existing = await db.idempotencyKeys.findOne({ key });
if (!existing) {
  const payment = await chargeCard(req); // both requests get here
  await db.idempotencyKeys.insertOne({ key, ... });
}
 
// ✅ Atomic insert acts as a distributed lock
async function createPayment(req: PaymentRequest, key: string) {
  const requestHash = hashRequest(req);
 
  try {
    await db.idempotencyKeys.insertOne({
      key,
      requestHash,
      status: "processing",
      createdAt: new Date(),
    });
  } catch (err) {
    if (isDuplicateKeyError(err)) {
      return waitForResultOrReturnConflict(key, requestHash);
    }
    throw err;
  }
 
  const payment = await chargeCard(req);
 
  await db.idempotencyKeys.updateOne(
    { key },
    { $set: { status: "completed", statusCode: 201, responseBody: payment } },
  );
 
  return { status: 201, body: payment };
}

Un índice único sobre key en tu base de datos hace que la inserción falle rápido para la solicitud perdedora, la cual entonces consulta periódicamente el resultado de la ganadora o devuelve un 409 indicándole al cliente que reintente tras una breve espera.

Cómo manejar la ventana de "todavía en proceso"

Entre la inserción atómica y la finalización de la operación, un reintento puede llegar en un estado donde la clave ya existe pero todavía no hay respuesta. No dejes a quien llama en segundo lugar esperando indefinidamente: decide explícitamente qué debe ocurrir.

Estado de la clave existenteRespuesta a enviar
processing, < 5s de antigüedad409 Conflict — "solicitud en curso, reintenta"
processing, obsoletaReintentar la operación: es probable que el worker original haya fallado
completedDevolver statusCode + responseBody guardados en caché
failedDevolver el error en caché, o permitir reintento si es transitorio

Esa rama de "obsoleta" importa más de lo que la gente espera. Si el proceso que maneja la solicitud original falla después de adquirir el bloqueo pero antes de completar la operación, todo reintento quedará atascado en 409 para siempre, a menos que tengas un tiempo de espera que reclame la clave.

tstypescript
const STALE_THRESHOLD_MS = 30_000;
 
function isStale(record: IdempotencyRecord): boolean {
  return (
    record.status === "processing" &&
    Date.now() - record.createdAt.getTime() > STALE_THRESHOLD_MS
  );
}

Expiración de claves y costos de almacenamiento

Las claves de idempotencia no pueden vivir para siempre: de lo contrario, acumularás filas de forma indefinida. La mayoría de los sistemas hacen expirar las claves tras 24 horas, un margen que cubre cómodamente las ventanas de reintento realistas (tiempos de espera del cliente, backoff exponencial, reintentos manuales del equipo de soporte) sin dejar entradas obsoletas acumulándose.

sqlsql
-- Postgres: TTL via a scheduled job, or a partial index + cron cleanup
CREATE TABLE idempotency_keys (
  key TEXT PRIMARY KEY,
  request_hash TEXT NOT NULL,
  status TEXT NOT NULL,
  status_code INT,
  response_body JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE INDEX idx_idempotency_created_at ON idempotency_keys (created_at);
 
-- Cleanup job, run hourly
DELETE FROM idempotency_keys WHERE created_at < now() - INTERVAL '24 hours';

Si usas Redis en lugar de un almacén relacional, esto es aún más simple: define un TTL en la clave al momento de escribirla y olvídate por completo del trabajo de limpieza.

Dónde deben usarse las claves de idempotencia

No todos los endpoints necesitan esto. Reserva las claves de idempotencia para operaciones que no son idempotentes por naturaleza y que tienen consecuencias reales si se duplican: pagos, creación de pedidos, envío de notificaciones, aprovisionamiento de infraestructura. No te molestes en envolver una actualización PUT /users/:id/profile: ya es naturalmente idempotente.

tstypescript
// ✅ Middleware applied selectively to mutation-heavy, consequential routes
router.post("/payments", requireIdempotencyKey, createPayment);
router.post("/orders", requireIdempotencyKey, createOrder);
router.post("/notifications/send", requireIdempotencyKey, sendNotification);
 
// Not needed — PUT is naturally idempotent
router.put("/users/:id/profile", updateProfile);

Conclusiones clave

  1. Guarda la respuesta, no solo la clave — los reintentos deben devolver exactamente el resultado original, no un error genérico de duplicado.
  2. Aplica un hash al cuerpo de la solicitud y compáralo en cada reintento — las claves reutilizadas con distintos cuerpos de solicitud son un error que necesita un fallo explícito, no un éxito silencioso.
  3. Haz que la inserción de la clave sea atómica y úsala como bloqueo — un patrón de "comprobar y luego insertar" tiene una condición de carrera bajo reintentos concurrentes.
  4. Maneja explícitamente la ventana de "en proceso" — decide qué ocurre cuando un reintento llega mientras la solicitud original todavía se está procesando, y reclama los bloqueos obsoletos.
  5. Haz que las claves expiren — 24 horas es un valor por defecto razonable que cubre las ventanas de reintento realistas sin un crecimiento ilimitado del almacenamiento.
  6. Aplica la idempotencia de forma selectiva — resérvala para operaciones consecuentes y no idempotentes, como pagos y creación de recursos, no para rutas que ya son seguras de reintentar.
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX