Saltar al contenido
Mathias Parodi

5 min de lectura

Idempotencia no es un lujo

La clave de idempotencia es la única barandilla entre tu API de pagos y un cobro duplicado. Casi todas las implementaciones que vi tienen el mismo agujero.

Un cliente manda un POST /payments. La red se corta antes de que llegue la respuesta. El cliente no sabe si el pago se hizo o no. ¿Qué hace? Reintenta. Todo el resto de este texto es sobre qué pasa en ese reintento.

La respuesta correcta es que no pase nada nuevo: que el segundo pedido devuelva exactamente lo mismo que hubiera devuelto el primero. Eso es idempotencia, y en pagos no es una optimización: es la diferencia entre cobrar una vez y cobrar dos.

La versión que casi funciona

La implementación con la que uno se cruza una y otra vez es más o menos esta:

pagos/crear.ts
export async function crearPago(req: Request) {
  const clave = req.headers.get("Idempotency-Key");
  if (!clave) return error(400, "falta Idempotency-Key");
 
  const previo = await db.idempotencia.buscar(clave);
  if (previo) return respuesta(previo.status, previo.body);
 
  const pago = await procesar(req.body);
 
  await db.idempotencia.guardar(clave, pago);
  return respuesta(201, pago);
}

Se lee bien. Y tiene tres agujeros, todos en el mismo lugar: el hueco entre buscar y guardar.

Agujero 1: dos pedidos a la vez

Si el cliente reintenta antes de que el primer pedido termine — que es exactamente lo que hace un cliente con timeout agresivo — los dos ejecutan buscar, los dos no encuentran nada, y los dos llaman a procesar. Dos cobros.

El arreglo no es un mutex en la aplicación: es dejar que la base de datos resuelva la exclusión. Se reserva la clave antes de procesar, con un INSERT que falla si ya existe.

esquema.sql
CREATE TABLE claves_idempotencia (
    clave           text        PRIMARY KEY,
    huella_pedido   text        NOT NULL,
    estado          text        NOT NULL,  -- 'en_curso' | 'terminado'
    codigo_http     smallint,
    cuerpo          jsonb,
    recurso_id      text,
    -- Dos relojes distintos, y conviene no confundirlos:
    creada_en       timestamptz NOT NULL DEFAULT now(),  -- cuándo empezó este intento
    vence_en        timestamptz NOT NULL                 -- hasta cuándo repetimos la respuesta
);
 
CREATE INDEX ON claves_idempotencia (vence_en);
pagos/crear.ts
export async function crearPago(req: Request) {
  const clave = exigirClave(req);
  const huella = huellaDe(req.body);
 
  const reserva = await db.query(
    `INSERT INTO claves_idempotencia (clave, huella_pedido, estado, vence_en)
     VALUES ($1, $2, 'en_curso', now() + interval '24 hours')
     ON CONFLICT (clave) DO NOTHING
     RETURNING clave`,
    [clave, huella],
  );
 
  if (reserva.rowCount === 0) {
    return await resolverConflicto(clave, huella);
  }
 
  // Somos los únicos con la reserva: acá adentro estamos solos.
  const pago = await procesar(req.body);
 
  await db.query(
    `UPDATE claves_idempotencia
        SET estado = 'terminado', codigo_http = 201,
            cuerpo = $2, recurso_id = $3
      WHERE clave = $1`,
    [clave, JSON.stringify(pago), pago.id],
  );
 
  return respuesta(201, pago);
}

El ON CONFLICT DO NOTHING hace todo el trabajo. Es una línea y es la única razón por la que no hay carrera.

Agujero 2: la misma clave con otro cuerpo

Nada impide que un cliente mande la misma Idempotency-Key con un monto distinto. Puede ser un bug de su lado, puede ser un ataque. Si devolvemos el resultado del primer pedido, le estamos confirmando un pago que nunca pidió.

Por eso guardamos la huella_pedido: un hash del cuerpo canonicalizado. Si la clave coincide pero la huella no, la respuesta correcta es un error.

pagos/conflicto.ts
async function resolverConflicto(clave: string, huella: string) {
  const fila = await db.idempotencia.buscar(clave);
 
  if (fila.huella_pedido !== huella) {
    // 422 y no 409: el pedido es sintácticamente válido pero
    // semánticamente incompatible con lo que esa clave ya representa.
    return error(422, "Idempotency-Key reutilizada con otro cuerpo");
  }
 
  if (fila.estado === "en_curso") {
    // El original sigue corriendo. No adivinamos el resultado:
    // pedimos que vuelva a preguntar.
    // OJO: esta rama todavía está incompleta. Ver "Agujero 3".
    return error(409, "pedido en curso", { "Retry-After": "1" });
  }
 
  return respuesta(fila.codigo_http, fila.cuerpo);
}

Agujero 3: el proceso se muere en el medio

Esa rama en_curso de recién está a medio hacer a propósito, porque asume algo que no siempre es cierto: que si la reserva existe, alguien la está trabajando.

La reserva quedó en en_curso y nadie la va a terminar nunca. El cliente reintenta, ve 409, reintenta de nuevo, ve 409 otra vez. Un pago inmovilizado para siempre por una clave zombi.

Acá hay dos mitades. La primera es un vencimiento: cualquier fila en_curso más vieja que el timeout máximo de la operación se puede reclamar.Ese timeout tiene que ser mayor que el peor caso real del adquirente, no que el promedio. Si el adquirente puede tardar 30 segundos, no pongas 10.

La segunda mitad es más incómoda: para poder reclamar la clave hay que saber si el cobro se hizo o no. Y eso no lo sabe tu base de datos, lo sabe el adquirente. Por eso la reserva guarda recurso_id y por eso el pago se crea con un identificador determinístico derivado de la clave: para poder preguntarle al adquirente “¿existe este pago?” antes de decidir.

Con las dos mitades puestas, resolverConflicto queda así:

pagos/conflicto.ts
// Cuánto puede tardar, en el peor caso real, una operación contra el
// adquirente. Es el reloj que decide si una reserva sigue viva o quedó zombi,
// y no tiene nada que ver con `vence_en`, que es cuánto tiempo seguimos
// repitiendo una respuesta ya terminada.
const TIMEOUT_OPERACION_MS = 60_000;
 
async function resolverConflicto(clave: string, huella: string) {
  const fila = await db.idempotencia.buscar(clave);
 
  if (fila.huella_pedido !== huella) {
    return error(422, "Idempotency-Key reutilizada con otro cuerpo");
  }
 
  if (fila.estado === "terminado") {
    return respuesta(fila.codigo_http, fila.cuerpo);
  }
 
  // `en_curso`. La pregunta ya no es "¿existe la reserva?" sino
  // "¿queda alguien del otro lado sosteniéndola?".
  const vencida =
    Date.now() - fila.creada_en.getTime() > TIMEOUT_OPERACION_MS;
 
  if (!vencida) {
    return error(409, "pedido en curso", { "Retry-After": "1" });
  }
 
  // Vencida: el dueño se murió o se colgó. Antes de tocar nada le
  // preguntamos al adquirente si el cobro llegó a existir. El id es
  // determinístico, así que se puede preguntar sin haber guardado nada.
  const enAdquirente = await adquirente.buscarPago(idDeterministico(clave));
 
  if (enAdquirente?.estado === "aprobado") {
    // El cobro se hizo. Se cierra la reserva con ese resultado y no se
    // reprocesa: reprocesar acá es exactamente el cobro duplicado que
    // toda esta nota trata de evitar.
    const pago = await cerrarReserva(clave, enAdquirente);
    return respuesta(201, pago);
  }
 
  if (enAdquirente?.estado === "pendiente") {
    // Existe pero no se resolvió. No es nuestro para reclamar: lo
    // va a cerrar el webhook.
    return error(409, "pedido en curso", { "Retry-After": "5" });
  }
 
  // No existe del lado del adquirente: recién acá la reserva es reclamable.
  // El UPDATE condicionado es lo que evita que dos reintentos simultáneos
  // la reclamen los dos — la misma idea que el ON CONFLICT de más arriba.
  const reclamo = await db.query(
    `UPDATE claves_idempotencia
        SET creada_en = now()
      WHERE clave = $1
        AND estado = 'en_curso'
        AND creada_en < now() - make_interval(secs => $2)
      RETURNING clave`,
    [clave, TIMEOUT_OPERACION_MS / 1000],
  );
 
  if (reclamo.rowCount === 0) {
    // Otro reintento la reclamó en el medio. Su intento gana.
    return error(409, "pedido en curso", { "Retry-After": "1" });
  }
 
  return await procesarYCerrar(clave);
}

Dos cosas que parecen detalle y no lo son. Una: el chequeo de vencimiento tiene que ir después del de terminado, porque una reserva terminada no vence en ese sentido — la respuesta guardada se sigue devolviendo mientras la clave viva. La otra: la reclamación se hace con un UPDATE que vuelve a exigir la condición de vencimiento en el WHERE. Leer y después escribir deja la misma carrera que el agujero 1, solo que corrida unos metros más adelante.

Estado de la reservaEstado en el adquirenteQué hacer
en_curso, vigente409 con Retry-After
en_curso, vencidano existereclamar y procesar
en_curso, vencidaexiste, aprobadocerrar la reserva con ese resultado
en_curso, vencidaexiste, pendiente409, esperar al webhook
terminadodevolver lo guardado

Esa tabla es, en la práctica, todo el diseño. El resto es plomería.

Lo que no es idempotencia

Dos confusiones que vale la pena separar.

No es lo mismo que un PUT. Que un método sea idempotente por definición del protocolo no dice nada sobre los efectos de tu implementación. Un PUT que manda un mail cada vez que corre no es idempotente en ningún sentido útil.

No alcanza con deduplicar por contenido. Dos pagos legítimos del mismo monto, al mismo comercio, en el mismo segundo, son dos pagos. Si los deduplicás por hash del cuerpo, le estás comiendo plata a alguien. La clave la tiene que elegir el cliente, y tiene que ser distinta para cada intención de cobro distinta.

El resumen

Guardá la clave antes de procesar, no después. Verificá que el cuerpo sea el mismo. Poné vencimiento a las reservas y decidí qué hacer cuando vencen. Preguntale al adquirente antes de asumir.

Un puñado de párrafos de código y una tabla de decisiones. Es poco trabajo para lo que evita.