El cliente hace un una petición POST a /orders. La conexión se cae justo después de que el servidor procese el pedido, pero antes de que la respuesta llegue de vuelta. El cliente reintenta la operación. El servidor recibe una segunda petición idéntica y crea un segundo pedido, duplicando la información. El cliente recibe una respuesta exitosa, factura al usuario dos veces, y nadie se entera hasta que llega el ticket de soporte (o peor, el cobro de la empresa).
No es un bug en el sentido "tradicional". El código hizo lo que estaba programado para hacer. El problema es que lo hizo dos veces. HTTP no tiene garantía de que una petición llega una única vez, y si la API asume que sí, hay un problema entre ambos contratos.
1. La suposición
Cuando una petición HTTP falla, el cliente puede distinguir tres escenarios diferentes:
- La petición nunca llegó al servidor.
- La petición llegó, pero el servidor falló antes de poder procesarla.
- La petición llegó y se procesó, pero la respuesta se perdió en el camino (por
timeout, perdida de conexión, un proceso reiniciando...).
Todos producen el mismo "silencio" de cara al usuario.
La reacción natural es reintentar. Asumir que la operación falló. Aunque esto puede ser incorrecto. El problema es que solo uno de esos tres escenarios corresponde de verdad a "no pasó nada". Reintentar en los otros dos significa repetir la operación sobre unos datos que ya han cambiado.
GET es idempotente por definición: leer el contenido dos veces no lo modifica. Pero POST no puede serlo por diseño. La mayoría de operaciones que importan son POSTs.
Cualquier operación detrás de un POST, PUT (no idempotente por naturaleza), o PATCH, puede producir estos escenarios. Cuanto más lenta sea la operación, más ancha la ventana en la que ocurra un timeout.
2. Claves de idempotencia. Qué son
Una clave de idempotencia es un identificador único (ID) que el cliente genera para una operación concreta. Esa clave se envía en la petición, normalmente en la cabecera, y se reutiliza al reintentar esa misma operación. Cuando el servidor recibe una petición con una clave que ya procesada, no vuelve a ejecutar la operación, sino que devuelve la respuesta generada la primera vez.
Dicha clave no describe qué hace la petición, solo identifica de forma única ese intento. Dos peticiones con la misma clave cuentan como la misma operación, aunque el body sea diferente (aunque comprobar ese contenido puede ser un guard interesante de implementar).
1// El cliente genera la clave una vez, antes del primer intento,
2// y la reutiliza en cada reintento de esa misma operación.
3import { randomUUID } from 'crypto'
4
5async function createOrder(payload: OrderPayload) {
6 const idempotencyKey = randomUUID()
7
8 return retryWithBackoff(() =>
9 fetch('/orders', {
10 method: 'POST',
11 headers: {
12 'Content-Type': 'application/json',
13 'Idempotency-Key': idempotencyKey,
14 },
15 body: JSON.stringify(payload),
16 })
17 )
18}El punto crítico es que la clave no la genera el servidor, sino que es el cliente quién se encarga de ello. En caso contrario, el servidor no tendría forma de reconocer un intento, ya que cada petición recibiría una clave diferente. El objetivo de la clave es precisamente para que el cliente pueda decir "esto es el mismo intento que antes", incluso sin saber si el intento anterior tuvo éxito o no.
3. Dónde y cómo guardar el resultado
Para poder utilizar la clave, es necesario que el servidor disponga de un sitio donde recordar esa clave y la respuesta asociada. Como mínimo una tabla o colección con la clave como identificador único, el estado de la operación, y el resultado
1type IdempotencyRecord = {
2 key: string
3 requestHash: string // hash del body, para detectar reutilización indebida
4 status: 'processing' | 'completed' | 'failed'
5 responseStatus?: number
6 responseBody?: unknown
7 createdAt: Date
8 expiresAt: Date // TTL: no se guarda para siempre
9}
10
11interface IdempotencyStore {
12 find(key: string): Promise<IdempotencyRecord | null>
13 create(record: IdempotencyRecord): Promise<void>
14 complete(key: string, status: number, body: unknown): Promise<void>
15}El requestHash permite que un cliente con un bug que reutiliza la misma clave para dos operaciones distintas no reciba la respuesta del pedido original, sin ningún error que avise de que algo no encaja. De esta forma, se puede comparar el hash del body contra el que se guardó la primera vez. Con eso, se puede lanzar un error como el 409 Conflict.
Guardar claves de idempotencia para siempre es una fuga de memoria con forma de tabla. Generalmente con un TTL de 24 horas es suficiente.
4. Por qué un simple SELECT no basta
El error más común al implementar esto por primera vez es comprobar si la clave existe, y si no existe, proceder a crear el recurso. Pero leer y luego escribir puede provocar una ventana de carrera; con dos peticiones con la misma clave llegando casi al mismo tiempo, ambas pueden hacer SELECT antes de que ninguna termine su INSERT, y por lo tanto, ambas acaban ejecutando la operación.
Para evitarlo, la creación del registro de idempotencia debe ser atómica, deforma que si se intenta insertar primero, y solo si el insert tiene éxito, se procede a ejecutar la operación de negocio. En caso de fallo (por que la clave ya existe), la petición consulta el resultado guardado, sin ejecutar ninguna acción más.
1async function handleIdempotentRequest(
2 key: string,
3 requestBody: unknown,
4 store: IdempotencyStore,
5 operation: () => Promise<{ status: number; body: unknown }>
6): Promise<{ status: number; body: unknown }> {
7 const requestHash = hashBody(requestBody)
8
9 const existing = await store.find(key)
10
11 if (existing) {
12 if (existing.requestHash !== requestHash) {
13 return { status: 409, body: { error: 'Idempotency key reused with a different payload' } }
14 }
15 if (existing.status === 'completed') {
16 return { status: existing.responseStatus!, body: existing.responseBody }
17 }
18 if (existing.status === 'processing') {
19 return { status: 409, body: { error: 'Request with this key is still processing' } }
20 }
21 }
22
23 // create() debe fallar (constraint de clave única en la BD) si otra
24 // petición concurrente ya insertó la misma key entre el find() y aquí.
25 try {
26 await store.create({
27 key,
28 requestHash,
29 status: 'processing',
30 createdAt: new Date(),
31 expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
32 })
33 } catch {
34 // Perdimos la carrera: otra petición concurrente ya está procesando esta key.
35 return { status: 409, body: { error: 'Request with this key is already being processed' } }
36 }
37
38 const result = await operation()
39 await store.complete(key, result.status, result.body)
40 return result
41}La garantía real no la da el código de la aplicación sino que es el UNIQUE de la columna key en la base de datos. Un find() inicial es una optimización para cuando la clave ya se resolvió. La protección real contra la carrera vive en que el segundo INSERT concurrente falla a nivel de base de datos, no en ninguna comprobación que se haga en memoria antes.
Si la idempotencia es una caché que vive en memoria del propio proceso, esta garantía se rompe cuando hay varias instancias corriendo. Dos peticiones con la misma clave pueden aterrizar en dos instancias distintas, cada una sin saber nada de la otra, y por lo tanto, ser válidas en sus propios contextos. El store tiene que compartirse entre todas las instancias.
5. Operaciones que ya son idempotentes
No toda operación necesita este mecanismo. Un PUT /users/42 que reemplaza por completo el recurso con un payload fijo ya es idempotente por definición, así que al ejecutarlo una vez o cinco veces diferentes, el recurso se queda en el mismo estado final. Añadir claves de idempotencia ahí es trabajo de más sin beneficio real.
Es en operaciones que crean algo nuevo cada vez que se ejecutan o que acumulan efecto con cada llamada (crear pedido, procesar cobro, enviar notificación...) donde es necesario añadir idempotencia. Si ejecutar una operación dos veces con el mismo input produce el mismo resultado, este mecanismo no es necesario. En caso contrario, habrá que incluirlo.
HTTP no cuenta con la idempotencia como característica propia. HTTP da una convención para comunicarla, pero es el código quién decide qué hacer con el estado.
6. Middleware genérico en Express
La forma correcta de aplicar el mecanismo del punto 4 es crear un middleware que envuelva cualquier ruta que lo necesite. Así, el handler de negocio se mantiene completamente libre de ess preocupación.
1import type { Request, Response, NextFunction } from 'express'
2
3function idempotent(store: IdempotencyStore) {
4 return async (req: Request, res: Response, next: NextFunction) => {
5 const key = req.header('Idempotency-Key')
6
7 if (!key) {
8 return res.status(400).json({ error: 'Idempotency-Key header is required' })
9 }
10
11 const requestHash = hashBody(req.body)
12 const existing = await store.find(key)
13
14 if (existing) {
15 if (existing.requestHash !== requestHash) {
16 return res.status(409).json({ error: 'Idempotency key reused with a different payload' })
17 }
18 if (existing.status === 'completed') {
19 return res.status(existing.responseStatus!).json(existing.responseBody)
20 }
21 return res.status(409).json({ error: 'Request with this key is already being processed' })
22 }
23
24 try {
25 await store.create({
26 key,
27 requestHash,
28 status: 'processing',
29 createdAt: new Date(),
30 expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
31 })
32 } catch {
33 return res.status(409).json({ error: 'Request with this key is already being processed' })
34 }
35
36 // Se intercepta res.json para capturar la respuesta del handler
37 // y guardarla asociada a la key, sin que el handler tenga que saberlo.
38 const originalJson = res.json.bind(res)
39 res.json = (body: unknown) => {
40 store.complete(key, res.statusCode, body)
41 return originalJson(body)
42 }
43
44 next()
45 }
46}
47
48// uso
49app.post('/orders', idempotent(idempotencyStore), async (req, res) => {
50 const order = await createOrder(req.body)
51 res.status(201).json(order)
52})Este middleware no necesita saber nada sobre pedidos, cobros o cualquier otro concepto de dominio. Solo conoce la parte técnica (clave, hash del body, cómo interceptar la respuesta). Eso significa que se puede reutilizar en cualquier endpoint que lo necesite añadiendo una sola línea. Además de que sus pruebas son independientes de cada handler.
7. Algunos errores comunes al implementar
- Usar
requestIdotraceIdcomo claves de idempotencia. Son cosas distintas: untraceIdnormalmente se genera de nuevo en cada intento para poder distinguir los logs, mientras que la clave de idempotencia tiene que ser exactamente la misma en todos los reintentos de la misma operación lógica. Mezclarlos hace que cada "reintento" parezca una operación nueva. - Guardar solo el
statusde la respuesta y no elbodycompleto. El cliente espera la misma respuesta que habría recibido la primera vez. Sin elbodyguardado, no hay forma de reconstruir esa respuesta. - No manejar el estado
processingde forma explícita. Si una petición larga sigue en curso y llega un reintento, la respuesta correcta es un409que le diga al cliente que espere y reintente más tarde, con la clave de la operación intacta.
Aplicar la clave de idempotencia después de que la operación haya iniciado un efecto externo irreversible, pero antes de guardar el resultado, puede provocar errores muy graves. Si el proceso muere justo ahí, el registro de idempotencia nunca llega a completed, y el reintento no sabrá que el cobro ya se hizo.
8. Conclusiones
La idempotencia no es una feature "exótica" reservada a pasarelas de pago. Es la respuesta correcta a un hecho básico de las redes: una petición puede fallar en tres puntos distintos y el cliente no puede distinguir entre ellos desde fuera. Reintentar sin un mecanismo de idempotencia asume, sin ninguna base, que todos los fallos son del tipo de "Nada pasó".
- Distinguir idempotencia de HTTP (una convención de cabeceras) de idempotencia real (una propiedad de cómo el código trata el estado).
- La clave la genera y reutiliza el cliente, nunca el servidor.
- Guardar el hash del body junto a la clave para detectar reutilización indebida con un payload distinto.
- La protección contra condiciones de carrera vive en una constraint única a nivel de base de datos, no en un
SELECTseguido de unINSERTen memoria. - Un store de idempotencia local a un proceso no funciona detrás de un balanceador con más de una instancia.
- Guardar el resultado completo (status y body), no solo un flag de éxito, y ponerle un TTL razonable.
No todas las operaciones necesitan esto. Las que sí lo necesitan (las que crean o acumulan algo cada vez que se ejecutan) son exactamente las que más duelen cuando salen mal dos veces en vez de una.
