Idempotencia en APIs — Por qué tu POST se ejecutó dos veces y cómo arreglarlo
Un pago cargado dos veces, un pedido duplicado o un usuario creado tres veces — estos son síntomas de la misma falta de contrato de API. Aquí se explica qué significa la idempotencia, por qué POST la rompe y cómo las claves de idempotencia la solucionan.
Su usuario hizo clic en Pagar ahora. El indicador giró. Timeout de red. Su lógica de reintentar se activó. La carga se procesó. Luego, la solicitud original también se completó desde el servidor — y la carga se procesó nuevamente. $200 retirado de la cuenta del cliente, y una incidencia de soporte esperando para usted en la mañana.
Este no es un caso raro. Es lo que ocurre cuando no piensas en la idempotencia antes de que la necesites. Vamos a solucionarlo.
Qué significa realmente la idempotencia
La palabra proviene de las matemáticas. Una operación es idempotente si aplicarla varias veces produce el mismo resultado que aplicarla una vez. f(f(x)) = f(x).
En términos de API: llamar al mismo endpoint con la misma intención N veces debe dejar al sistema en el mismo estado que al hacerlo una vez. La respuesta puede ser un resultado almacenado en caché, pero los efectos secundarios — las escrituras en la base de datos, las cargas, los correos — deben ocurrir solo una vez.
Es una garantía sobre lo que su servidor limita la cookie a solo, no solo sobre lo que devuelve.
Métodos HTTP: quiénes están seguros y quiénes no
El especificación de HTTP designa ciertos métodos como idempotentes por definición. Aquí está el desglose práctico:
| Método | Idempotente? | Por qué |
|---|---|---|
GET | ✅ Sí | Solo lectura. Sin efectos secundarios por definición. |
HEAD | ✅ Sí | Igual que GET, sin cuerpo devuelto. |
PUT | ✅ Sí | "Establecer este recurso en exactamente este estado". Llamarlo dos veces produce el mismo resultado. |
DELETE | ✅ Sí | El recurso desaparece tras la primera llamada. Las llamadas posteriores no encuentran nada para eliminar. (El código de estado puede variar — 204 frente a 404 — pero el estado del servidor no cambia). |
POST | ❌ No | "Procesar este payload". Lo que significa esto depende del servidor. Dos POSTs típicamente crean dos recursos o desencadenan dos efectos secundarios. |
PATCH | ⚠️ Depende | Una actualización relativa ("increment count by 1") no es idempotente. Una actualización absoluta ("set count to 5") sí lo es. Su implementación determina cuál. |
El problema es que la mayoría de las operaciones comerciales reales — cargar una tarjeta, crear un pedido, enviar una notificación — se corresponden naturalmente con POST. Y POST no ofrece garantías de idempotencia de forma nativa.
La secuencia que te lleva
Aquí está el modo clásico de falla, paso a paso:
Client Network Server
|
|--- POST /payments ------>| |
| |--- (delivered) -------->|
| | processing...
| | card charged ✓
|<-- (connection drops) ---| response queued
|
| [retry logic kicks in]
|
|--- POST /payments ------>| |
| |--- (delivered) -------->|
| | processing...
| | card charged ✓ (again)
|<------- 200 OK ----------|<------------------------|
|
[client sees: one charge. server did: two charges]
El cliente nunca vio la primera respuesta. Desde su perspectiva, la solicitud falló. Desde la perspectiva del servidor, se completó dos veces. Este es un clásico en sistemas distribuidos — el cliente y el servidor han divergido sobre lo que ocurrió.
Esto no es solo para procesadores de pagos. Es para la creación de pedidos, registro de usuarios, envíos de correos, reservas de inventario — cualquier cosa en la que "ejecutar dos veces" tenga consecuencias reales.
Claves de idempotencia: el patrón de Stripe
Stripe popularizó el enfoque que ahora usan la mayoría de los APIs de pagos. El cliente genera una clave única antes de enviar la solicitud y la adjunta como encabezado. El servidor utiliza esa clave para deduplicar.
Lado del cliente:
// Generate once, before any retry loop
const idempotencyKey = crypto.randomUUID();
async function chargeWithRetry(amount, retries = 3) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const response = await fetch('/api/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey, // same key on every retry
},
body: JSON.stringify({ amount, currency: 'usd' }),
});
if (response.ok) return await response.json();
// Don't retry on client errors (4xx)
if (response.status < 500) throw new Error(`Client error: ${response.status}`);
} catch (err) {
if (attempt === retries - 1) throw err;
await sleep(Math.pow(2, attempt) * 1000); // exponential backoff
}
}
}
La clave debe generarse antes en cualquier bucle de reintentos — esa es la esencia. Si generas un nuevo UUID en cada intento, has anulado completamente el mecanismo.
Un UUID v4 funciona bien aquí. Si necesitas generar uno rápidamente mientras pruebas, IO Tools' generador de UUID te permite producir un lote de ellos sin necesidad de incluir una biblioteca.
Lado del servidor: almacenar la respuesta, devolverla en repetición
La tarea del servidor es conceptualmente sencilla: verificar si esta clave ha sido vista antes; si sí, devolver la respuesta almacenada; si no, procesarla y almacenarla.
// Express + Redis
app.post('/api/payments', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
if (idempotencyKey) {
const cached = await redis.get(`idem:${idempotencyKey}`);
if (cached) {
const { status, body } = JSON.parse(cached);
return res.status(status).json(body);
}
}
const result = await paymentProcessor.charge(req.body);
const responseBody = { id: result.id, status: result.status };
if (idempotencyKey) {
// 24-hour TTL — same window Stripe uses
await redis.setex(
`idem:${idempotencyKey}`,
86400,
JSON.stringify({ status: 200, body: responseBody })
);
}
res.json(responseBody);
});
Algunos detalles que importan:
- Fallas en caché, no solo éxitos. Si la carga falla (tarjeta rechazada), almacena también esa respuesta de error. De lo contrario, un reintentar intentará la carga nuevamente aunque ya haya devuelto un error — lo cual es precisamente lo que estás tratando de evitar.
- Validar la consistencia del cuerpo. Stripe devuelve 422 si se reutiliza la misma clave con parámetros diferentes de solicitud. Esto detecta errores en los que accidentalmente se reutiliza una clave entre operaciones diferentes.
- Manejar duplicados en tránsito. Dos solicitudes con la misma clave llegando simultáneamente necesitan coordinación — procesar una y devolver 409 en la otra, o usar un bloqueo distribuido. SETNX de Redis es el patrón común aquí.
- Establecer un TTL razonable. Después de la expiración, las claves pueden reciclarse y las nuevas solicitudes con la misma clave se tratan como nuevas. No almacénlas para siempre — tu caché se volverá grande.
Errores del lado del cliente que envían dos POST sin un reintentar
Las claves de idempotencia protegen contra los reintentos en la capa de red. No ayudan cuando el cliente envía dos solicitudes separadas e independientes. Los culpables comunes:
- Doble clic en el botón de envío. El usuario hace clic, no ve nada, vuelve a hacer clic. Deshabilita el botón inmediatamente en el primer clic — no después de que llegue la respuesta. La brecha entre el clic y la respuesta es exactamente cuando el segundo clic llega.
- Invocación doble en React StrictMode. React 18 Strict Mode ejecuta efectos dos veces en desarrollo para detectar errores. Si estás enviando un POST en un
useEffectsin limpieza, verás solicitudes duplicadas en desarrollo. No ocurre en producción, pero puede ocultar el problema real. - Reenvío de formularios en el navegador. Enviar → navegar → volver → adelante. Algunos navegadores preguntan "¿reenviar?", otros simplemente lo hacen. El patrón POST-Redirect-GET (devolver una redirección tras un POST) elimina esto por completo.
- Condiciones de carrera en manejadores de eventos. Un clic y una rápida presión de Enter activan ambos manejadores de envío antes de que llegue la primera respuesta y deshabiliten el formulario.
- Reintentos en segundo plano en aplicaciones móviles. La lógica de recuperación en segundo plano de iOS y Android puede repetir una solicitud que ya ha sido enviada por la app. Genera claves de idempotencia en el momento de la intención del usuario, almacénlas localmente si es necesario, y elimínalas solo tras un éxito confirmado.
Una alternativa que vale la pena considerar: PUT a una URL con UUID
Si controlas ambos extremos de la API, hay una opción estructuralmente más limpia para la creación de recursos: que el cliente asigne el ID del recurso y use PUT en lugar de POST.
# Instead of this (POST, not idempotent):
POST /orders
{"amount": 99.00, "items": [...]}
# Do this (PUT to client-generated UUID, idempotent by spec):
PUT /orders/7f3b9c2a-4e5d-4f8b-9a1c-2d3e4f5a6b7c
{"amount": 99.00, "items": [...]}
PUT es idempotente por especificación de HTTP — "establecer este recurso en este estado" significa que volver a llamar a PUT no tiene efecto adicional una vez que el recurso exista. El servidor maneja el duplicado con INSERT ... ON CONFLICT DO NOTHING o equivalentes.
Este patrón funciona bien para la creación de pedidos, gestión de borradores y cualquier recurso en el que la consistencia del ID en los reintentos sea importante. No funciona cuando un tercero (un procesador de pagos) asigna el ID, o cuando el efecto secundario debe ocurrir en el servidor antes de que sepas el ID.
Qué parece la implementación real de Stripe
La documentación de claves de idempotencia vale la pena leer en su totalidad. Algunos detalles que importan en la práctica:
- Formato de clave: Cualquier cadena de hasta 255 caracteres, limitada a tu cuenta de Stripe. Dos cuentas diferentes usando la misma cadena no se afectan entre sí.
- Ventana de 24 horas: Las claves caducan después de 24 horas. Un reintentar después de la expiración se trata como una solicitud nueva — útil de saber si estás construyendo flujos de trabajo largos.
- Diferencia en cuerpo = 422: Misma clave, parámetros diferentes → Stripe devuelve 422 Unprocessable Entity. Este es el comportamiento correcto; detecta el error en que accidentalmente se reutiliza una clave.
- Deduplicación concurrente: Si dos solicitudes con la misma clave llegan simultáneamente, Stripe procesa una y devuelve 409 Conflict en la otra. Reintenta el 409 tras un breve retraso.
- Fallas almacenadas en caché: Si la carga falla (tarjeta rechazada), Stripe almacena esa falla. Reintentar con la misma clave devuelve el mismo rechazo. Necesitas una nueva clave para intentar otra tarjeta — lo cual es correcto, porque la intentación anterior fue una operación completa e intencional.
Pruebas de idempotencia sin una suite de pruebas de integración completa
La forma más rápida de verificar el comportamiento es hacer dos veces el endpoint con la misma clave y comprobar que la segunda llamada devuelva la respuesta almacenada sin provocar nuevamente el efecto secundario. IO Tools' generador de comandos cURL facilita construir la solicitud con encabezados personalizados — incluyendo Idempotency-Key — sin tener que memorizar la sintaxis de los flags de cURL.
Escenarios a cubrir:
- Misma clave + mismo cuerpo, dentro del TTL → respuesta almacenada devuelta, efecto secundario se activa una vez
- Misma clave + cuerpo diferente → 422 (o tu estado de conflicto elegido)
- Misma clave después de que caduque el TTL → tratada como solicitud nueva
- Sin clave proporcionada → procesada normalmente (decide de antemano si las claves son obligatorias o opcionales)
- Dos solicitudes concurrentes con la misma clave → una procesa, otra recibe 409
La versión breve
POST no es idempotente, y esa brecha entre "solicitud enviada" y "respuesta recibida" es donde viven los efectos secundarios duplicados. La solución no es complicada: genera un UUID antes de cualquier bucle de reintentos, envíalo como encabezado, almacena la respuesta en el servidor indexada por ese encabezado. Las partes difíciles son los detalles — almacenar errores, manejar duplicados concurrentes, elegir el TTL adecuado — pero el patrón principal es simple suficiente para añadirlo a cualquier endpoint que importe.
Agrega soporte para claves de idempotencia antes de que la primera tráfico en producción llegue. Retrofitarlo después de un incidente de carga doble es un momento significativamente peor para aprender la lección.
Instalar extensiones
Agregue herramientas IO a su navegador favorito para obtener acceso instantáneo y búsquedas más rápidas
恵 ¡El marcador ha llegado!
Marcador es una forma divertida de llevar un registro de tus juegos, todos los datos se almacenan en tu navegador. ¡Próximamente habrá más funciones!
Herramientas clave
Ver todo Los recién llegados
Ver todoActualizar: Nuestro última herramienta fue agregado el 16 de junio de 2026
