Comprender las claves de idempotencia en los puntos finales POST de Node.js
Explica por qué las solicitudes POST fallan de manera impredecible al intentarse nuevamente y cómo las claves de idempotencia generadas por el cliente permiten que las APIs de Node.js manejen con seguridad solicitudes duplicadas.
La idempotencia es un término que se encuentra disperso en la documentación de las APIs de pago, generalmente acompañado por una definición del diccionario que todos revisan rápidamente sin realmente comprenderla. A continuación, se presenta un intento de explicarla a través de las preguntas que los desarrolladores realmente plantean cuando se encuentran con ella en el código de producción, en lugar de la versión abstracta que encontrarían en un libro de texto.
¿Qué significa realmente “idempotente” cuando estás escribiendo código, y no leyendo un glosario?
Una operación se considera idempotente cuando ejecutarla una vez produce el mismo estado final que ejecutarla cinco veces seguidas con exactamente los mismos datos de entrada. Tomemos PUT /users/8/name con un cuerpo de { "name": "Jane" }: ya sea que se llame una vez o cinco veces, el nombre del usuario terminará siendo “Jane” y no se acumulará nada. Comparemos esto con POST /orders que tiene como objetivo crear un nuevo pedido: si se llama cinco veces, es probable que obtengamos cinco pedidos separados y no uno, porque nada en la operación impide que se acumulen.
¿Por qué esto es tan importante específicamente con las solicitudes POST?
POST suele ser el método encargado de crear elementos, y las redes tienen una forma específica de fallar que lo vuelve peligroso: una solicitud puede completarse con éxito en el lado del servidor mientras que el cliente nunca se entera, ya que la respuesta se pierde en algún punto del camino de regreso. Desde la perspectiva del cliente, lo único que ve es un tiempo de espera agotado. No tiene forma de saber si la solicitud realmente se procesó, por lo que hace lo único sensato que puede: vuelve a intentarlo.
// the client's perspective, roughly
async function submitOrder(payload) {
try {
return await fetch("/orders", { method: "POST", body: JSON.stringify(payload) });
} catch {
return submitOrder(payload); // did the first one actually fail, or just the response?
}
}
Si el endpoint /orders no está diseñado para soportar ese tipo de intentos repetidos, el cliente termina pagando dos veces por una misma compra, y ninguna de las partes es claramente culpable. Según lo que podía observar el cliente, la solicitud realmente falló. Pero desde el punto de vista del servidor, realmente tuvo éxito.
Entonces, ¿qué se necesita para que un endpoint POST en Node sea realmente idempotente?
La solución convencional consiste en que el cliente genere una clave única por cada operación lógica, la adjunte como encabezado y permita que el servidor utilice esa clave para reconocer una solicitud reintentada como la misma que ya procesó, en lugar de tratarla como algo nuevo.
app.post("/orders", async (req, res) => {
const idempotencyKey = req.headers["idempotency-key"];
if (!idempotencyKey) {
return res.status(400).json({ error: "Idempotency-Key header required" });
}
const existing = await db.query(
"SELECT response_body, status_code FROM idempotency_keys WHERE key = $1",
[idempotencyKey]
);
if (existing) {
return res.status(existing.status_code).json(JSON.parse(existing.response_body));
}
const order = await createOrder(req.body);
await db.query(
"INSERT INTO idempotency_keys (key, response_body, status_code) VALUES ($1, $2, $3)",
[idempotencyKey, JSON.stringify(order), 201]
);
res.status(201).json(order);
});
Es responsabilidad del cliente reutilizar esa misma clave cada vez que vuelva a intentar la misma solicitud lógica; generalmente se trata de un UUID creado una sola vez, justo antes de que se envíe el primer intento. La tarea del servidor es más sencilla: reconocer una clave que ya ha visto y devolver el resultado almacenado en lugar de volver a realizar el trabajo.
¿Quién debe generar la clave de idempotencia, el cliente o el servidor?
Tiene que ser el cliente, y esto sorprende a mucha gente porque su instinto les dice lo contrario. Si fuera el servidor quien generara la clave, cada intento de repetición llegaría con una nueva, lo que haría que todo el mecanismo fuera inútil: el servidor no tendría forma de distinguir un intento de repetición de una nueva solicitud. La clave debe existir antes incluso de que se envíe el primer intento, precisamente para poder volver a utilizar el mismo valor si es necesario repetir ese intento.
¿Qué pasa si dos solicitudes idénticas llegan literalmente al mismo momento, en lugar de una tras otra?
Esta es la parte que casi todos los primeros intentos con este patrón cometen al error. El enfoque sencillo de “verificar y luego insertar” mostrado anteriormente tiene una condición de carrera incorporada: dos solicitudes con la misma clave pueden ejecutar su SELECT, ambas devolver resultados vacíos y seguir adelante para crear un pedido, lo que socava por completo el propósito de la clave desde un principio.
// safer: let the database's own uniqueness constraint catch the race
app.post("/orders", async (req, res) => {
const idempotencyKey = req.headers["idempotency-key"]; try {
await db.query("INSERT INTO idempotency_keys (key) VALUES ($1)", [idempotencyKey]);
} catch (err) {
if (err.code === "23505") { // unique constraint violation
const existing = await db.query(
"SELECT response_body, status_code FROM idempotency_keys WHERE key = $1",
[idempotencyKey]
);
return res.status(existing.status_code).json(JSON.parse(existing.response_body));
}
throw err;
}
const order = await createOrder(req.body);
await db.query(
"UPDATE idempotency_keys SET response_body = $1, status_code = $2 WHERE key = $3",
[JSON.stringify(order), 201, idempotencyKey]
);
res.status(201).json(order);
});
Establecer una restricción única en la columna key desplaza la toma de decisiones de la lógica de tu aplicación hacia la propia base de datos: cuando dos solicitudes simultáneas entran en conflicto, es la base de datos quien decide cuál tiene prioridad, y la otra recibe un error claro y detectable en lugar de pasar desapercibida. Una instrucción if en el manejador de rutas por sí sola no puede cerrar esta brecha; los problemas de concurrencia como este deben resolverse en la capa que realmente serializa el acceso, y esa capa es la base de datos, no una verificación condicional en tu código.
¿Tiene algo de esto importancia también para las solicitudes GET?
No de la misma manera, y esto confunde a las personas con frecuencia. GET ya debe ser idempotente por diseño: no debería alterar nada, por lo que volver a intentarlo libremente es inherentemente seguro sin necesidad de ningún tratamiento especial. El patrón idempotency-key existe específicamente para operaciones que crean o modifican estado, donde una repetición descuidada duplicaría el efecto. Si un endpoint GET no es seguro para ser llamado repetidamente, el verdadero problema es que está realizando efectos secundarios que en absoluto debería tener según la semántica de GET.
¿Cuánto tiempo debe permanecer válido una clave de idempotencia?
Idealmente, lo suficiente como para cubrir escenarios realistas de repetición, pero no tanto que las claves almacenadas se acumulen indefinidamente. Muchas plataformas de pago establecen un plazo entre 24 horas y unos pocos días. Un trabajo de limpieza programado puede entonces eliminar las entradas vencidas:
await db.query("DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours'");
Si el período de tiempo se establece de forma demasiado estricta, una repetición retrasada por una razón legítima —por ejemplo, el teléfono de un cliente pierde señal durante diez minutos en medio del proceso de pago— podría quedar fuera de ese período y provocar un duplicado real. Si, por otro lado, se deja abierto indefinidamente, la tabla seguirá creciendo sin ningún beneficio real.
¿Solo merece atención este patrón en los sistemas de pago?
Los pagos suelen ser donde la gente aprende esta lección por primera vez, sobre todo porque un cargo duplicado es el tipo de error que genera un correo electrónico furioso del cliente en cuestión de horas. Pero el problema subyacente —un cliente que no puede distinguir entre “mi solicitud falló” y “mi solicitud tuvo éxito pero nunca recibí respuesta”— aparece en cualquier situación donde haya un efecto secundario: enviar un correo electrónico, activar un webhook, crear una nueva cuenta o iniciar un trabajo en segundo plano. Cualquier operación para la cual sea plausible intentarla de nuevo, y en la que ejecutarla dos veces sea peor que no ejecutarla en absoluto, es un buen candidato para este mismo enfoque.
Lecturas relacionadas
- Diseño de API en Node.js con capas: de controladores complejos a arquitectura limpa — Aprenda cómo refactorizar una API de Node.js en capas de controlador, servicio y acceso a datos para solucionar la lógica empresarial enredada, los errores inconsistentes y las dificultades de escalado.
- 20 patrones de Node.js que evitan el paro del servidor en producción — Conozca 20 patrones prácticos de Node.js, desde el manejo de errores hasta el apagado ordenado y la gestión de conexiones en pool, que evitan caídas antes de que sea necesario reiniciar el servidor.