Regresar
505

Idempotencia: por qué importa en pagos y reintentos

Actualizado: 15/09/2026

El usuario pulsa pagar. La pantalla se queda cargando. Pasan quince segundos y no ocurre nada, así que vuelve a pulsar. O cierra y entra de nuevo. O el propio código, al no recibir respuesta, reintenta por su cuenta.

Esa secuencia es cotidiana y encierra un problema que no se puede resolver del lado del cliente: quien envía una petición y no recibe respuesta no tiene forma de saber si la operación ocurrió. La red puede haber fallado antes de llegar, o después de procesar y antes de contestar.

La incertidumbre que no se puede eliminar

Conviene asumirlo con claridad porque de ahí sale todo lo demás: una respuesta perdida no es distinguible de una petición perdida. Desde fuera, ambas son silencio.

Ante ese silencio hay dos opciones, y las dos pueden estar mal. Si no se reintenta, el pago podría no haberse hecho y el pedido queda sin cobrar. Si se reintenta, podría haberse hecho ya y se cobra dos veces.

La solución no consiste en adivinar cuál de las dos cosas pasó. Consiste en hacer que reintentar sea seguro, de modo que la duda deje de importar.

Eso es la idempotencia: la propiedad de que ejecutar una operación varias veces produzca el mismo resultado que ejecutarla una sola vez.

El mismo reintento, dos resultados distintos

La diferencia práctica se ve mejor comparando los dos escenarios.

Arriba, sin clave de idempotencia: el cliente pide cobrar 50, la respuesta se pierde, reintenta la misma operación y se acaba cobrando 100. Abajo, con clave de idempotencia: el cliente envía la misma clave en ambos intentos, el servidor reconoce que ya la vio y el resultado final es un solo cobro de 50
El cliente no puede saber si la operación ocurrió. La clave permite al servidor responder lo mismo dos veces.

La clave no cambia lo que hace la operación: cambia quién decide si ya se hizo. Sin ella, esa decisión recae en el cliente, que no tiene información suficiente. Con ella, la toma el servidor, que sí la tiene.

Lo que ya es idempotente sin hacer nada

Antes de añadir mecanismos conviene saber que buena parte de las operaciones lo son por su propia naturaleza.

Consultar un dato no cambia nada, así que repetirlo es inofensivo. Fijar un valor absoluto tampoco: establecer el estado de un pedido como enviado deja el mismo resultado se ejecute una vez o cinco. Borrar un registro concreto por su identificador, igual.

El problema aparece con las operaciones relativas: sumar, restar, incrementar, añadir a una lista, enviar. Ahí cada ejecución cambia el resultado.

De ahí sale una regla de diseño que resuelve muchos casos antes de llegar a la clave: preferir fijar valores absolutos a aplicar incrementos. Actualizar el saldo a cien es seguro de repetir; sumarle diez, no.

La clave de idempotencia, en la práctica

Cuando la operación no se puede expresar en términos absolutos, la solución estándar es que el cliente genere un identificador único para ese intento y lo envíe en una cabecera.

Lo importante es que la clave identifique la intención, no el envío. Se genera una vez, cuando el usuario decide pagar, y se reutiliza en todos los reintentos de ese mismo pago. Si se generara en cada envío, cada reintento traería una clave distinta y no serviría de nada.

// Se genera al construir la operación, NO en cada envío
const clave = crypto.randomUUID();

async function pagar(intentos = 0) {
    const r = await fetch('/api/pagos', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Idempotency-Key': clave        // la misma en todos los reintentos
        },
        body: JSON.stringify({ pedido: 8841, importe: 50 })
    });

    if (r.status >= 500 && intentos < 3) {
        await new Promise(r => setTimeout(r, 2 ** intentos * 1000));
        return pagar(intentos + 1);
    }
    return r.json();
}

Del lado del servidor, la comprobación debe ocurrir dentro de la misma transacción que la operación, o dos peticiones simultáneas pueden pasar ambas la verificación antes de que ninguna haya terminado.

Dónde se guarda y qué se guarda

El detalle que separa una implementación correcta de una que falla bajo concurrencia está en usar la base de datos para arbitrar, en lugar de comprobar antes y escribir después.

CREATE TABLE operaciones_idempotentes (
    clave        text PRIMARY KEY,
    huella       text NOT NULL,           -- resumen del cuerpo recibido
    estado       text NOT NULL,           -- en_curso | completada
    respuesta    jsonb,
    creada_en    timestamptz NOT NULL DEFAULT now()
);

-- Intentar reservar la clave: si ya existe, no hace nada
INSERT INTO operaciones_idempotentes (clave, huella, estado)
VALUES ('abc-123', 'f4a9...', 'en_curso')
ON CONFLICT (clave) DO NOTHING;

Si la inserción no afecta a ninguna fila, la clave ya existía y hay que mirar en qué estado está. Si está completada, se devuelve la respuesta guardada sin volver a ejecutar nada. Si está en curso, significa que otra petición idéntica se está procesando ahora mismo, y lo correcto es responder que hay un conflicto para que el cliente reintente en unos segundos.

Guardar la respuesta original es la parte que más se olvida y la que hace que el sistema se comporte bien: el segundo intento debe recibir exactamente lo mismo que el primero, no un mensaje distinto diciendo que ya existía.

La huella del cuerpo, que evita un error silencioso

Hay un caso que conviene contemplar: la misma clave llegando con un contenido distinto.

Ocurre por errores de programación, por claves reutilizadas sin querer o por un cliente que modifica el importe y reintenta. Si el servidor solo mira la clave, devolverá la respuesta de la operación anterior y el segundo pago, con otro importe, nunca se ejecutará.

La protección es guardar un resumen del cuerpo junto a la clave y comparar. Si la clave coincide pero el contenido no, la respuesta correcta es un error explícito, no la respuesta antigua.

$huella = hash('sha256', $cuerpo);

if ($fila['huella'] !== $huella) {
    http_response_code(422);
    echo json_encode(['error' => 'La clave ya se usó con un contenido distinto']);
    exit;
}

Es un caso poco frecuente y cuando ocurre, sin esta comprobación, produce un fallo silencioso que se descubre semanas después cuadrando cuentas.

Cuánto tiempo conservar las claves

La tabla crece con cada operación, así que necesita una política de retención, y elegir el plazo tiene consecuencias en ambas direcciones.

Demasiado corto y un reintento tardío, por ejemplo de un proceso que estuvo caído unas horas, encuentra la clave ya borrada y ejecuta la operación por segunda vez. Demasiado largo y la tabla acumula millones de filas que ya no protegen de nada.

Veinticuatro horas es el plazo habitual en la industria y cubre la práctica totalidad de los reintentos reales. Para operaciones de dinero conviene ser más generoso, porque los procesos de conciliación pueden llegar días después.

-- Limpieza diaria de claves antiguas
DELETE FROM operaciones_idempotentes
 WHERE creada_en < now() - interval '30 days';

Conviene que esa limpieza esté programada desde el principio. Una tabla de claves sin borrado es de las que crecen en silencio hasta que alguien nota que la base ocupa mucho más de lo que debería.

Dónde más aparece esto, además de en pagos

El título habla de pagos porque es donde más duele, y el mismo problema aparece en bastantes sitios que no se suelen asociar con él.

  • Webhooks entrantes. El proveedor reenvía si no respondes a tiempo, así que el mismo evento llega varias veces y hay que ignorar los repetidos.
  • Colas de mensajes. La garantía habitual es de entrega al menos una vez, lo que significa que el consumidor debe tolerar duplicados.
  • Envío de correos y notificaciones. Un reintento tras un fallo aparente envía el mensaje dos veces al usuario, que es un fallo visible y molesto.
  • Formularios de alta. Un doble clic crea dos registros si nada lo impide.
  • Trabajos programados. Si un proceso se reinicia a mitad, la siguiente ejecución puede repetir lo ya hecho.

En todos ellos la solución tiene la misma forma: un identificador estable de la operación, guardado, y una comprobación antes de actuar.

Reintentar bien, no solo reintentar

La idempotencia hace seguro el reintento; cómo se reintenta decide si ayuda o empeora las cosas.

Reintentar de inmediato ante un servicio caído añade carga a algo que ya está en problemas. Cada intento debe esperar más que el anterior, con un margen aleatorio para que muchos clientes no reintenten a la vez tras una caída general.

También importa qué se reintenta. Un error del servidor o un tiempo agotado merecen reintento; un error de validación o de autorización no, porque el resultado va a ser el mismo y solo genera ruido.

Y hace falta un tope. Un reintento sin límite ante un fallo permanente es un bucle que consume recursos indefinidamente, y termina apareciendo en la factura antes que en los registros.

Qué se le escapa a una IA con esto

Pedir una integración de pagos o un consumidor de cola produce código que funciona en el camino feliz y no contempla el reintento. Es la omisión más consistente de esta familia, porque el caso solo aparece cuando algo falla.

Cuando sí se pide idempotencia, los dos errores habituales son generar la clave en cada envío, con lo que no protege de nada, y comprobar la existencia antes de insertar en lugar de dejar que la base arbitre, con lo que dos peticiones simultáneas pasan ambas.

Las frases que cambian la respuesta son pedir que la clave se genere una sola vez por intención del usuario, que la comprobación use una restricción de unicidad dentro de la transacción, y que se guarde la respuesta para devolverla idéntica en los reintentos.

Y conviene probarlo explícitamente antes de dar nada por bueno: lanzar la misma petición dos veces seguidas con la misma clave y comprobar que solo hay un cargo y que ambas respuestas son iguales.

# La misma operación, enviada dos veces con la misma clave
for i in 1 2; do
  curl -s -X POST http://localhost:8080/api/pagos \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: prueba-abc-123' \
    -d '{"pedido":8841,"importe":50}' | jq -c .
done

# Ambas respuestas deben ser idénticas, y el cargo debe aparecer una sola vez

Guía de referencia intermedio