Explicación de la idempotencia y qué significa para tu API
Claves de idempotencia explicadas: evita solicitudes API duplicadas, corrige condiciones de carrera y reintenta POST con claims atómicos y transacciones.
Una operación es idempotente cuando ejecutarla varias veces deja el sistema en el mismo estado que ejecutarla una sola vez. Envías la misma petición dos veces y no ocurre nada adicional: ni un segundo pedido, ni un segundo cargo, ni una segunda cuenta.
La palabra suele aparecer en uno de estos dos escenarios: un cargo duplicado en producción, o una API de pagos que exige una cabecera Idempotency-Key sin explicar demasiado qué hace el servidor con ella. Este artículo cubre ambas mitades de ese contrato: qué hace el cliente con la clave y qué hace el servidor para que una petición repetida sea inofensiva en lugar de casi siempre inofensiva.
Puntos clave
- Una clave de idempotencia tiene tres estados, no dos: ausente, en curso y completada. Una petición que encuentra la clave en curso debería recibir un 409 Conflict, no una segunda ejecución.
- Comprobar si una clave existe y escribirla después es precisamente la condición de carrera; la única forma segura de reclamarla es un único insert atómico antes de que comience cualquier trabajo.
- El resultado almacenado debe confirmarse en la misma transacción de base de datos que el cambio de negocio; confirmarlos por separado solo desplaza la condición de carrera.
- El cliente genera la clave antes del primer intento, la reutiliza en cada reintento y nunca la deriva aplicando un hash al cuerpo de la petición.
- Prueba lanzando dos peticiones idénticas en el mismo instante. Un test de duplicados que las ejecuta una tras otra pasa incluso cuando el código tiene una condición de carrera.
¿Qué previene la idempotencia?
La idempotencia te protege de las peticiones duplicadas, y las peticiones duplicadas son algo cotidiano, no excepcional. Un usuario hace doble clic en enviar antes de que la página reaccione. Una librería cliente agota el tiempo de espera de una respuesta y reenvía. Un proxy o un service mesh reintenta ante una conexión caída sin que la aplicación se entere. En todos los casos la primera petición puede haber tenido éxito, así que la segunda, procesada de forma ingenua, crea un segundo pedido o mueve el dinero dos veces. Las repeticiones de sesión de errores por doble envío suelen mostrar la versión más mundana: el usuario pulsando enviar otra vez mientras el spinner sigue en pantalla, que es la mitad del lado cliente del mismo problema que la clave resuelve en el servidor.
Por qué los reintentos siguen llegando
Un reintento no es un fallo. Los clientes HTTP, las apps móviles y toda la infraestructura intermedia reenvían peticiones tras un timeout por diseño, porque una respuesta perdida es indistinguible de una petición perdida. No puedes evitar que lleguen reintentos; solo puedes hacer que tu endpoint sea seguro al recibir la misma petición dos veces.
RFC 9110 define PUT y DELETE como idempotentes mientras que POST no lo es, y PATCH, definido en RFC 5789, tampoco es idempotente. La etiqueta del método nunca hace que tu handler sea seguro por sí sola; la idempotencia es una propiedad de tu implementación, no del verbo.
La mitad del contrato que corresponde al cliente
El cliente genera la clave de idempotencia antes del primer intento, envía la misma clave en cada reintento de esa operación y usa una clave nueva solo para una operación genuinamente nueva. Una cadena aleatoria de alta entropía como un UUID funciona bien. La guía de Stripe recomienda un UUID V4, un máximo de 255 caracteres y nada sensible dentro de la propia clave: ni direcciones de correo ni otros identificadores personales, porque las claves acaban en los logs.
Nunca derives la clave aplicando un hash al cuerpo de la petición. Dos pedidos que resulten ser idénticos, como el mismo cliente comprando el mismo artículo dos veces seguidas, producirían el mismo hash y se fusionarían en uno solo. Un hash te dice si dos payloads coinciden; una clave te dice a qué operación se refería quien llamó. Son trabajos distintos. Derivar la clave de algo estable sobre lo que el usuario ya está actuando, como el ID de un carrito, funciona perfectamente, porque el carrito representa la operación.
Ten en cuenta que la cabecera Idempotency-Key es una convención de la industria, no un estándar ratificado. El borrador del grupo de trabajo httpapi del IETF expiró en la revisión 07 sin llegar a convertirse en RFC, así que cada proveedor define su propia semántica.
La mitad del servidor: reclamar la clave de forma atómica
Una clave de idempotencia tiene tres estados, no dos: ausente, en curso y completada. La mayoría de las implementaciones defectuosas modelan solo dos. Comprueban si la clave existe, ejecutan el handler y luego guardan el resultado. Eso deja una ventana en la que dos reintentos concurrentes no ven nada y ambos se ejecutan. La solución es reclamar la clave con un único insert atómico antes de que comience cualquier trabajo:
INSERT INTO idempotency_keys
(tenant_id, idem_key, fingerprint, state, locked_until)
VALUES
($1, $2, $3, 'in_flight', now() + interval '90 seconds')
ON CONFLICT (tenant_id, idem_key) DO NOTHING
RETURNING id;
En PostgreSQL, ON CONFLICT DO NOTHING omite el insert y RETURNING no devuelve ninguna fila para una clave en conflicto, así que cero filas devueltas significa que otra petición es su propietaria. Lee la fila existente: si su estado es complete, reproduce el resultado almacenado; si sigue en in_flight, devuelve 409 Conflict en lugar de ejecutar una segunda vez. Esto coincide con el comportamiento documentado de los proveedores: Stripe devuelve 409 Conflict cuando se reutiliza una clave mientras la primera petición sigue en curso, y no registra ese conflicto contra la clave, de modo que el cliente puede volver a intentarlo más tarde. Stripe también etiqueta una respuesta reproducida con la cabecera Idempotent-Replayed: true, una cortesía barata que merece la pena copiar.
Confirma el resultado junto con el cambio de negocio
El resultado almacenado y el cambio de negocio deben confirmarse en la misma transacción de base de datos. Escribirlos por separado no elimina la condición de carrera, la desplaza al hueco entre ambos commits. Si el proceso muere después del cargo pero antes de actualizar la clave, el dinero se ha movido mientras la fila sigue marcada como in_flight.
BEGIN;
INSERT INTO orders (tenant_id, customer_id, total_cents)
VALUES ($1, $2, $3);
UPDATE idempotency_keys
SET state = 'complete', status_code = 201, response_body = $4
WHERE tenant_id = $1 AND idem_key = $5;
COMMIT;
O existen ambas filas o no existe ninguna, que es justamente de lo que se trata.
¿Qué deberías almacenar asociado a la clave, y durante cuánto tiempo?
Almacena todo lo que produjo el handler, incluidos sus fallos. Según las reglas de idempotencia de Stripe, el código de estado y el cuerpo del primer intento se conservan y se devuelven de nuevo al reutilizar la clave, incluidas las respuestas de error y los 500. Reproducir un error real es más honesto que ejecutar silenciosamente la operación por segunda vez. La frontera real es todo aquello que se rechaza antes de que el handler se ejecute. La limitación de tasa y la autenticación se sitúan delante de la capa de idempotencia, así que esas respuestas nunca se asocian a la clave y siguen siendo reintentables.
Tres opciones de almacenamiento, en breve:
- Respuesta completa. La más sencilla para reproducir exactamente; el almacenamiento crece con el tamaño del payload.
- Referencia al recurso. Almacena el ID del pedido creado y reconstruye la respuesta; más ligero, pero requiere una consulta adicional.
- Marcador más fingerprint de la petición. Almacenamiento mínimo; solo viable cuando la respuesta se puede recalcular, y el fingerprint pasa a ser obligatorio en lugar de opcional.
Se aplican cuatro reglas independientemente de la opción elegida. Pon la restricción de unicidad sobre (tenant_id, key) y no sobre la clave por sí sola, para que un tenant no pueda colisionar con las claves de otro tenant ni ir a la pesca de ellas. Establece una caducidad: Stripe elimina las claves una vez superadas las 24 horas, y el principio es sobrevivir a la ventana de reintentos sin dejar que la tabla crezca indefinidamente. Pon un lease sobre las filas en curso (la columna locked_until de arriba) para que un proceso que muera a mitad de la petición no pueda bloquear los reintentos para siempre. Y rechaza cualquier reintento cuyo fingerprint no coincida con el almacenado. La misma clave con un cuerpo distinto apunta a un bug del cliente, y devolver una respuesta no relacionada sería un resultado aún peor.
¿Cómo se prueba correctamente la idempotencia?
Lanza dos peticiones idénticas con la misma clave en el mismo instante y luego comprueba que existe exactamente un recurso. Ejecutar los duplicados uno tras otro no demuestra nada, porque el primero termina antes de que el segundo mire, así que un código con condición de carrera pasa la prueba.
KEY=$(uuidgen)
for i in 1 2; do
curl -s -o "resp_$i.json" -w "%{http_code}\n" \
-X POST http://localhost:3000/orders \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"cart_id":"c_42","total_cents":1900}' &
done
wait
Verifica que hay una sola fila en orders para ese carrito, y que los dos códigos de estado son un 201 más un 201 reproducido o un 409. Si ambos devolvieron 201 con IDs de pedido distintos, tienes la condición de carrera de comprobar-y-después-escribir.
Tres cosas que hay que hacer bien
La cabecera solo da a dos sistemas un nombre compartido para una operación. La seguridad en sí viene de tres cosas en tu base de datos: una restricción de unicidad, una reclamación atómica y un límite transaccional. Hazlas bien y tu endpoint sobrevivirá a cualquier cliente que reintente, es decir, a todos los clientes. El mismo enfoque se traslada a los consumidores de mensajes, donde la entrega at-least-once implica una clave de deduplicación haciendo este mismo trabajo bajo otro nombre. Empieza por tu endpoint POST más peligroso, añade la tabla de claves y escribe el test concurrente antes de confiar en él.
Preguntas frecuentes
¿Las peticiones GET y PUT necesitan claves de idempotencia?
Normalmente no. RFC 9110 define GET como seguro y PUT y DELETE como idempotentes, así que un PUT reintentado que reemplaza por completo un recurso deja el mismo estado sin necesidad de clave. Las claves importan en POST, donde cada petición crea algo nuevo. La excepción es un handler PUT o DELETE con efectos secundarios, como enviar un correo o disparar un webhook, que sigue necesitando deduplicación en el servidor.
¿Cuál es la diferencia entre una clave de idempotencia y un ID de petición?
Se comportan de forma opuesta entre reintentos. Un ID de petición o ID de correlación identifica un único intento HTTP para logging y trazabilidad, así que cada reintento recibe uno nuevo. Una clave de idempotencia identifica una operación pretendida, así que cada reintento reutiliza la misma. Un cliente que genera una clave de idempotencia nueva en cada reintento anula por completo la deduplicación, y el servidor ejecuta la operación dos veces.
¿Puedo almacenar las claves de idempotencia en Redis en lugar de PostgreSQL?
Sí para la reclamación atómica: SET con el flag NX reclama una clave en un único paso atómico, equivalente al patrón de insert-on-conflict. Lo que Redis no puede darte es una única transacción que confirme el resultado de la clave junto con una fila de negocio almacenada en otro sitio. Un fallo entre la escritura en Redis y el commit en la base de datos reabre la condición de carrera, así que mantener las claves en la base de datos de negocio es más seguro.
¿Qué ocurre si un cliente reintenta después de que la clave de idempotencia haya caducado?
El servidor trata el reintento como una petición completamente nueva y la ejecuta otra vez, lo que puede crear un duplicado. Stripe, por ejemplo, elimina las claves una vez superadas las 24 horas, así que una clave reutilizada después de esa ventana ejecuta la operación por segunda vez. Configura tu periodo de retención por encima del mayor retardo de reintento que cualquier cliente, cola o trabajo por lotes pueda producir de forma plausible.