Idempotency-Key: 8 casos donde la implementación trivial falla

Idempotency-Key: 8 casos donde la implementación trivial falla

@programacion

Lo esencial

  • Implementar Idempotency-Key como cache de respuestas solo cubre el happy path: el segundo request idéntico tras un timeout.
  • El post viral lista 8 escenarios donde la lógica naive falla: retries concurrentes, crash parcial, downstream desconocido.
  • Misma key con comando canonical distinto (mismo Idempotency-Key, monto distinto) debería ser hard error, no replay silencioso.
  • PUT y DELETE son idempotentes por semántica HTTP; POST necesita Idempotency-Key porque crear dos veces tiene side effects.
  • Expiración de key, retry tras deploy con schema nuevo y region failover son decisiones de policy, no detalles de implementación.
  • Replay es un contrato del API: la respuesta debe ser la misma aunque el recurso haya cambiado entre la primera y la segunda llamada.
  • Post viral en HN (211 puntos, mayo 2026) reabre la discusión con casos prácticos; Stripe documenta el problema desde hace años.

Qué es Idempotency-Key y por qué importa

Cuando un cliente llama a una API que cobra una tarjeta, envía un email o crea una orden, la red puede fallar en mitad del camino. El cliente no sabe si el servidor procesó la operación o no. Si reintenta, corre el riesgo de cobrar dos veces. La solución estándar es Idempotency-Key: un identificador único que el cliente genera y manda en el header. El servidor recuerda el resultado de la primera llamada con esa key y, si llega una segunda con la misma key, devuelve la respuesta cacheada en vez de procesar de nuevo.

El 7 de mayo de 2026 un post en blog.dochia.dev llegó a 211 puntos en Hacker News con una tesis incómoda: la implementación que casi todo el mundo escribe — guardar la key con la respuesta y replicarla en el segundo request — solo cubre el caso fácil. Hay al menos ocho escenarios reales donde esa lógica falla y el bug aparece como cobros duplicados, eventos perdidos o estados inconsistentes.

La implementación naive (y por qué se siente correcta)

El esqueleto típico es algo así:

async function handleCharge(req) {
const key = req.headers['idempotency-key'];
const cached = await store.get(key);
if (cached) return cached.response;

const result = await chargeCard(req.body);
await store.set(key, { response: result });
return result;
}

Funciona perfecto cuando el cliente reintenta exactamente el mismo request después de un timeout. El problema es que ese es solo uno de los caminos posibles. Los otros siete son los que rompen producción.

Caso 1 y 2: replay completo y retry concurrente

El replay completo es el happy path: el primer request terminó, guardaste el resultado, el segundo lo lee del cache. Trivial.

El retry concurrente ya no lo es. El cliente tiene un timeout corto, el servidor tarda 6 segundos, y el cliente reintenta antes de que el primer request termine. Ahora hay dos workers procesando la misma operación en paralelo. Sin un lock atómico sobre la key, ambos llaman al proveedor de pagos y cobran dos veces. La solución es escribir la key como pending antes de empezar y rechazar (o esperar) si llega un segundo request mientras está pending.

Caso 3 y 4: éxito parcial y estado desconocido downstream

El éxito parcial local: cobraste la tarjeta, escribiste la fila en la base, pero crasheaste antes de publicar el evento payment.succeeded. El cliente reintenta, encuentra la respuesta cacheada y recibe el OK. Pero el evento nunca salió. El sistema downstream (envío de email, generación de factura, actualización de inventario) nunca se enteró. La transacción de base de datos no cubre la publicación del evento — necesitás outbox pattern o two-phase commit.

El estado desconocido downstream: llamaste al proveedor de pagos, el proveedor crasheó antes de responder, vos crasheaste antes de guardar. El cliente reintenta. ¿Cobraste o no? Sin un check al proveedor (que también necesita su propia idempotency key, idealmente la misma), no podés saberlo.

Caso 5: misma key, comando distinto

Este es el caso que la mayoría de implementaciones manejan mal. El cliente reintenta con el mismo Idempotency-Key pero el body es distinto: la primera llamada cobraba 100 dólares, la segunda cobra 200. ¿Qué hace el servidor?

  • Opción A: ignorar el body — devolvés la respuesta cacheada de los 100 dólares aunque el cliente pidió 200. El cliente cree que cobró 200, el sistema cobró 100. Bug silencioso.
  • Opción B: procesar de nuevo — cobrás los 200 además de los 100. Doble cobro.
  • Opción C (la recomendada por el autor): misma scoped key + comando canonical distinto = hard error. Devolvés 422 y el cliente sabe que tiene un bug. Atrapás el problema temprano en vez de propagarlo.

El comando canonical es un hash determinístico de los campos que importan: monto, moneda, destino. No el body crudo (que puede tener whitespace, orden de keys distinto, timestamps). Stripe documenta esta política desde hace años: si la key existe pero los parámetros difieren, devuelven error.

Casos 6, 7 y 8: tiempo, deploys y regiones

  • Retry tras expiración — guardás keys por 24 horas. El cliente reintenta a las 25 horas. Para tu cache es un request nuevo, lo procesás otra vez. El TTL es parte del contrato del API: documentalo o el cliente asumirá lo que quiera.
  • Retry tras deploy o cambio de schema — la primera versión guardó la respuesta serializada con un schema viejo. Tras el deploy, deserializar la respuesta cacheada falla o devuelve campos faltantes. ¿Replay o reproceso?
  • Retry tras region failover — la primera llamada llegó a us-east-1, la segunda a us-west-2 tras un failover. Si el store de keys no está replicado globalmente con consistencia fuerte, la región nueva no ve la key y procesa la operación otra vez.

HTTP method semantics: por qué POST necesita la key

La especificación HTTP define PUT y DELETE como idempotentes por construcción: hacer PUT /users/42 diez veces deja al usuario 42 en el mismo estado que hacerlo una vez. POST no: POST /charges diez veces crea diez cargos. Por eso Idempotency-Key existe principalmente para POST. Si tu API usa PUT con un identificador determinado por el cliente (PUT /charges/{client-uuid}), parte del problema desaparece sin necesidad de header extra.

La regla simple del autor

Para APIs con side effects, la postura del post es:

Misma scoped key + comando canonical distinto = hard error. Misma key + mismo comando = replay. Key expirada o ausente = request nuevo.

Y replay no es una conveniencia, es un contrato: la respuesta del segundo request debe ser idéntica a la del primero, incluso si el estado del recurso cambió entre medio. Si el cargo original quedó en estado failed y el cliente reintenta, devolvés failed — no procesás de nuevo solo porque sería más útil.

Por qué este post pegó tanto

Idempotency es uno de esos temas que todo el mundo cree dominar hasta que pierde dinero por un bug en producción. La discusión en Hacker News se llenó de ingenieros contando cómo descubrieron cada uno de los ocho casos a la mala — cobros duplicados, emails enviados dos veces, eventos perdidos en queues con consumers naive. La conclusión transversal: el header es la parte fácil. Los detalles son política — TTL, scope, qué cuenta como mismo comando, qué hacer en mismatch — y esa política tiene que estar escrita en la documentación del API, no inferida por cada cliente.

Conclusión

Idempotency-Key resuelve un problema real, pero la implementación trivial solo cubre uno de ocho caminos. El resto requiere decisiones explícitas: locks atómicos contra retries concurrentes, outbox pattern contra crashes parciales, hash canonical contra commands distintos, TTL documentado, replicación cross-region. El header es protocolo. La política es producto.

📖 Versión extendida con más detalle: https://elsolitario.org/2026/05/10/idempotency-key-segundo-request-distinto/?utm_source=telegraph&utm_medium=instant_view&utm_campaign=programacion

Report Page