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:
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.
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);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.
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í:
// 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 reserva | Estado en el adquirente | Qué hacer |
|---|---|---|
en_curso, vigente | — | 409 con Retry-After |
en_curso, vencida | no existe | reclamar y procesar |
en_curso, vencida | existe, aprobado | cerrar la reserva con ese resultado |
en_curso, vencida | existe, pendiente | 409, esperar al webhook |
terminado | — | devolver 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.