⏱️ Lectura: 14 min

Si tu API recibe el mismo request dos veces, ¿qué pasa? La respuesta de manual es: implementá Idempotency-Key, guardá la primera respuesta y devolvela en los siguientes intentos. El 7 de mayo de 2026 un post en blog.dochia.dev llegó al top de Hacker News con 211 puntos por una razón incómoda: esa implementación cubre exactamente un caso, el happy path.

📑 En este artículo
  1. TL;DR
  2. Qué significa idempotencia (más allá del eslogan)
  3. La implementación naive y su trampa
  4. Los ocho casos donde la lógica naive se rompe
    1. 1. Reintento idéntico tras éxito (happy path)
    2. 2. Reintento concurrente con la primera request todavía en vuelo
    3. 3. Misma key con comando canonical distinto
    4. 4. Éxito parcial local antes de publicar el evento
    5. 5. Estado desconocido downstream tras crash
    6. 6. Retry tras expiración de la key
    7. 7. Retry tras deploy o cambio de schema
    8. 8. Retry tras failover de región
  5. La postura del autor: hard error en mismatch
  6. Cómo implementarlo bien
  7. Casos análogos: consumers de cola
  8. Preguntas frecuentes
    1. ¿Stripe está mal por ser permisivo en mismatch de body?
    2. ¿Necesito Idempotency-Key si mi endpoint es PUT?
    3. ¿Qué TTL debería tener mi cache de idempotency?
    4. ¿Sirve usar el hash del body como Idempotency-Key?
    5. ¿Qué pasa si el cliente nunca envía Idempotency-Key?
    6. ¿Cómo testeo todo esto?
  9. Referencias

En cuanto aparece la primera situación real —reintento concurrente, misma key con cuerpo distinto, crash entre la escritura local y el evento downstream, failover de región— la lógica naive produce dobles cobros, eventos perdidos o estados imposibles. Vamos a desarmar los ocho escenarios donde falla y qué hace falta para que la idempotencia sea real, no decorativa.

TL;DR

  • Idempotency-Key + replay cache solo cubre el reintento idéntico tras éxito; no cubre concurrencia ni cambios de cuerpo.
  • Misma key con cuerpo distinto (monto cambiado) debería devolver 422: atrapa bugs del cliente temprano.
  • Reintento concurrente con la primera request en vuelo necesita lock distribuido por key, no solo lookup.
  • Crash entre escribir local y publicar evento downstream: el cobro queda hecho pero el resto del sistema no se entera.
  • Estado desconocido tras timeout del proveedor: replay no sirve, hay que reconciliar contra el endpoint de status.
  • Si la key expira a las 24h y el cliente reintenta a las 25h, hay doble cobro silencioso.
  • Retry tras deploy con schema cambiado puede devolver respuestas inválidas para el parser actual del cliente.
  • PUT y DELETE son idempotentes por contrato HTTP; POST no. Semántica del método no equivale a idempotencia de negocio.

Qué significa idempotencia (más allá del eslogan)

En matemática, una operación f es idempotente si f(f(x)) = f(x): aplicarla dos veces da el mismo resultado que aplicarla una. Llevado al mundo de los servicios, idempotente es la operación que produce el mismo efecto observable sin importar cuántas veces la llames con los mismos argumentos.

El RFC 9110 marca como idempotentes por contrato los métodos HTTP GET, HEAD, PUT, DELETE y OPTIONS. POST y PATCH no lo son. Pero esto es semántica del método, no una garantía automática del servidor. Si tu handler de PUT /users/42 manda un correo de bienvenida cada vez que se invoca, el método sigue siendo idempotente para HTTP, pero el efecto de negocio (el correo) no lo es. La diferencia importa: el cliente puede reintentar un PUT con la confianza de que reaplicarlo es seguro si y solo si el servidor implementó la idempotencia operacional.

El caso interesante es POST. Crear recursos no es idempotente por defecto: un segundo POST crea un segundo recurso. Para que un POST sea seguro de reintentar, el cliente envía un Idempotency-Key (típicamente un UUID) y el servidor usa esa clave para detectar duplicados. Acá empieza la trampa.

La implementación naive y su trampa

El patrón que popularizó Stripe y que copió media internet es brutalmente simple: el cliente envía Idempotency-Key en un header, el servidor guarda la combinación clave + respuesta durante 24 horas, y los reintentos con la misma clave devuelven la respuesta cacheada sin volver a ejecutar.

@app.post("/charge")
def charge():
    key = request.headers.get("Idempotency-Key")
    cached = cache.get(key)
    if cached:
        return cached
    response = process_charge(request.json)
    cache.set(key, response, ttl=86400)
    return response

Hermoso. Pocas líneas, fácil de revisar, fácil de explicar en una entrevista. Y resuelve exactamente un caso: el cliente reintentó el mismo request, sin cambios, después de que el primero terminó y respondió. Cualquier desviación —y en producción todas son desviaciones— rompe alguno de los invariantes que el código asume sin decirlo.

Diagrama de flujo de Idempotency Key naive con cache simple
El happy path es el único escenario donde el cache simple alcanza.

Los ocho casos donde la lógica naive se rompe

1. Reintento idéntico tras éxito (happy path)

El único escenario que la naive cubre. El primer request termina, devuelve 200, el cliente recibe el response pero su socket se corta antes del ack. Reintenta con la misma key, el server lee el cache y devuelve la misma respuesta. Sin doble efecto. Hasta acá todo bien.

2. Reintento concurrente con la primera request todavía en vuelo

El cliente envía el request, el server lo está procesando, y el cliente alcanza un timeout local antes de recibir respuesta. Reenvía. Ahora dos workers están procesando el mismo cobro en paralelo: ambos hacen cache.get(key), ambos reciben miss, ambos llaman a process_charge. Doble cobro silencioso.

La fix requiere un lock distribuido por key antes de hacer el trabajo: SETNX en Redis con TTL corto, o un advisory lock en Postgres. El segundo worker espera al primero o devuelve 409 Conflict. El cache simple no es un lock: leer del cache no impide que otro worker lea el mismo miss.

3. Misma key con comando canonical distinto

El cliente envía Idempotency-Key: abc con un body que dice {"amount": 100}. Por un bug, reintenta con la misma key pero {"amount": 1000}. ¿Qué hace tu servidor?

  • Si solo indexás por key: devolvés la respuesta del $100 a un cliente que cree haber cobrado $1000.
  • Si no validás nada y la naive ni siquiera mira el cuerpo: la lógica de negocio decide qué hacer, casi siempre mal.

Stripe es permisivo: ignora silenciosamente el cambio de body y devuelve la respuesta cacheada. El autor del post argumenta que la postura correcta para APIs con side effects es hard error: misma scoped key + comando canonical distinto = 422. La razón es operacional: un mismatch siempre es un bug del cliente, y un 422 lo manifiesta en el primer request en lugar de enmascararlo. La naive ni siquiera detecta el caso porque no hashea el cuerpo.

4. Éxito parcial local antes de publicar el evento

Persistís el cobro en la base, todavía no publicaste el evento charge.created en Kafka, y el proceso muere. Cliente reintenta. Tu cache no tiene la key (no llegaste a escribirla), entonces reejecutás. Resultado: dos rows en la base de cobros, ninguna en el topic. O peor: un row en la base y dos eventos publicados después del segundo intento.

El antídoto es el outbox pattern: la escritura del cobro y la inserción del evento van en la misma transacción a una tabla de outbox, y un worker separado publica al broker leyendo de esa tabla. La idempotencia se vuelve responsabilidad del consumer downstream, que ya tiene que tolerar duplicados de todos modos.

5. Estado desconocido downstream tras crash

Llamás al proveedor de pagos. Timeout. ¿Cobró o no? No sabés. Reintentar el request del cliente con la misma key te devuelve la respuesta cacheada (si la guardaste antes del timeout) o reejecuta (si no llegaste). Ninguna de las dos opciones es correcta cuando el estado real está en otro sistema.

La única salida real es reconciliación activa: pollear el endpoint de status del proveedor con la idempotency key que le mandaste para saber qué pasó. Esto solo es posible si el proveedor también soporta idempotency keys (Stripe, Adyen, MercadoPago sí). El replay del request original no resuelve el problema; lo enmascara.

6. Retry tras expiración de la key

Tu TTL es 24 horas. El cliente reintenta a las 25 horas porque el job que orquesta los reintentos tiene backoff exponencial generoso o porque la cola se atascó durante un mantenimiento. Tu cache vacía, reejecutás, doble cobro silencioso.

La expiración no es un detalle de implementación: es parte del contrato de tu API. Si decís TTL de 24h, los clientes deben saber que reintentos posteriores a 24h pueden ejecutarse de nuevo y que la idempotencia ya no aplica. Documentarlo no es opcional. La política de qué pasa con keys expiradas también es una decisión: ¿rechazás con 410 Gone o ejecutás como nueva?

7. Retry tras deploy o cambio de schema

Cobrás $100 y respondés con el formato v1: {"status": "ok", "amount": 100}. Hacés deploy y la v2 cambia el formato a {"result": "success", "total_cents": 10000}. El cliente reintenta con la misma key, recibe la respuesta v1 cacheada, y su parser v2 explota.

O peor: cacheaste objetos serializados con un campo que ya no existe en la nueva clase, y deserializar lanza excepción. Hay dos estrategias: cachear en formato versionado y migrar al leer, o invalidar el cache en cada deploy de schema (con su correspondiente espacio para reejecuciones). Ninguna es gratis. Ignorar el problema funciona hasta el día del primer cambio breaking.

8. Retry tras failover de región

Tu DB principal en us-east-1 cae. Failover a us-west-2. La key del request del cliente vivía en el cache local de us-east que no replicó a tiempo. El cliente reintenta, pega en us-west, no encuentra la key, reejecuta. Doble cobro.

La idempotencia debe vivir en un store que sea consistente cross-region (con su penalty de latencia) o el contrato debe declarar abiertamente que durante un failover existe una ventana donde la garantía no aplica. Esa decisión también es parte del API contract, no algo que se descubra leyendo logs después del incidente.

Esquema de idempotency key con lock distribuido y outbox pattern
La implementación correcta combina lock por key, hash del comando y outbox.

La postura del autor: hard error en mismatch

El núcleo del post es una decisión de diseño: cuando llega la misma scoped key con un comando canonical distinto, ¿qué responder?

  • Permisivo (Stripe, default de la mayoría): ignorar el cuerpo nuevo, devolver la respuesta cacheada. Suena seguro: nada se ejecuta dos veces. En la práctica enmascara bugs del cliente que terminan apareciendo como discrepancias de saldo semanas después.
  • Estricto (la propuesta del autor): devolver 422 Unprocessable Entity con un payload que diga “esta key ya existe asociada a un comando distinto”. El cliente recibe la falla en el primer reintento y puede inspeccionar la divergencia.

El autor lo plantea como un trade-off de fricción versus correctitud: el modo estricto es más exigente para el cliente (tiene que generar keys nuevas cuando cambia el comando), pero hace imposible la categoría de bugs donde el cliente cambia el monto y el sistema, en silencio, no aplica el cambio.

💭 Clave: La idempotencia no es solo una optimización de reintentos — es un contrato. Y los contratos solo funcionan si ambos lados los respetan: el cliente garantizando que la misma key implica el mismo comando, el servidor verificándolo activamente con un hash del cuerpo canonical.

Cómo implementarlo bien

La implementación correcta para una operación con side effects (un cobro, una transferencia, una creación de orden) tiene cinco piezas trabajando juntas:

sequenceDiagram
  participant C as Cliente
  participant S as Server
  participant L as Lock
  participant DB as Base
  C->>S: POST charge key abc
  S->>L: SETNX key abc TTL 30s
  L-->>S: ok adquirido
  S->>DB: BEGIN
  S->>DB: INSERT charge
  S->>DB: INSERT outbox event
  S->>DB: INSERT idempotency record
  S->>DB: COMMIT
  S->>L: DEL key abc
  S-->>C: 201 created
  1. Lock por key antes de leer o escribir nada. Bloquea el caso del reintento concurrente.
  2. Hash del comando canonical guardado junto con la respuesta. Permite detectar el caso del mismatch y devolver 422.
  3. Outbox pattern para que la publicación de eventos no se separe del estado local. Bloquea el caso del éxito parcial.
  4. TTL documentado en el contrato público de la API y política operativa para reintentos posteriores.
  5. Endpoint de reconciliación que el cliente pueda llamar cuando quedó en estado desconocido tras un timeout, en vez de reintentar a ciegas.
⚠️ Ojo: El lock distribuido es necesario pero no suficiente. Si el lock expira por TTL antes de que termine la operación (porque el handler tardó más de lo previsto), volvés al caso del reintento concurrente. El TTL del lock debe ser mayor que el peor caso de duración del handler, no menor, y el handler debe renovar el lock si va a tardar más.

Casos análogos: consumers de cola

El problema no es exclusivo de APIs HTTP. Cualquier consumer de Kafka, SQS, RabbitMQ o NATS enfrenta exactamente las mismas ocho situaciones cuando el broker reintrega un mensaje. La idempotencia operacional es una propiedad de sistemas distribuidos, no del transporte.

En consumers, la “key” suele ser el offset del mensaje o un identificador del payload. Las soluciones son las mismas: lock por key, validación de comando canonical, outbox para eventos derivados, reconciliación contra estado externo. Los frameworks de mensajería que prometen exactly-once delivery en realidad ofrecen at-least-once delivery + idempotencia del consumer: el broker garantiza la entrega, el consumer garantiza no duplicar. Cualquier promesa más fuerte oculta uno de los componentes.

💡 Tip: Si vas a documentar tu API, agregá una sección dedicada a idempotencia que cubra: política de TTL, comportamiento en mismatch de cuerpo, comportamiento ante key expirada, y endpoints de reconciliación disponibles. Es la diferencia entre un cliente que reintenta con confianza y uno que termina abriendo un ticket.

📖 Resumen en Telegram: Ver resumen

Preguntas frecuentes

¿Stripe está mal por ser permisivo en mismatch de body?

No está “mal”: tomó una decisión consciente de favorecer la simplicidad para el cliente sobre la detección temprana de bugs. Es defendible para un mercado con miles de integradores que no leen la documentación. Para una API interna o de pocos consumidores, el modo estricto suele ser preferible porque atrapa bugs cuando todavía son baratos de arreglar.

¿Necesito Idempotency-Key si mi endpoint es PUT?

Para idempotencia de transporte HTTP, no: PUT ya es idempotente por contrato. Para idempotencia de side effects (envío de notificaciones, cobros derivados, eventos publicados), sí. La semántica HTTP no garantiza la operacional, y reintentos de PUT pueden disparar emails duplicados si tu handler los manda en cada invocación.

¿Qué TTL debería tener mi cache de idempotency?

Stripe usa 24 horas. Para operaciones financieras críticas tiene sentido extender a 7 días: la diferencia de costo en almacenamiento es mínima frente al riesgo de doble cobro por reintento tardío. Lo importante es documentarlo y que el cliente conozca el límite.

¿Sirve usar el hash del body como Idempotency-Key?

No es lo mismo. La key debe ser generada por el cliente y representar la intención. El hash del body solo representa el estado actual del request: dos requests intencionalmente distintos con el mismo body (ej. dos cobros separados del mismo monto al mismo cliente, mismo día) recibirían la misma key y se trataría uno como duplicado del otro. La key vive en la capa de intención, el hash en la capa de comando.

¿Qué pasa si el cliente nunca envía Idempotency-Key?

Vos decidís el contrato: rechazar con 400, generar uno automático y devolverlo en un header (poco útil porque el cliente no puede reintentar con la misma), o ejecutar sin garantías. Stripe permite la tercera con un warning. APIs estrictas exigen siempre el header.

¿Cómo testeo todo esto?

Test de chaos por escenario: matá el proceso entre la escritura del cobro y la del evento, simulá doble request concurrente con dos workers, falsificá un cambio de schema, expira el cache manualmente, simulá un failover. Cada uno de los ocho casos tiene un test asociado. Si tu suite no los cubre, no tenés idempotencia, tenés intención.

Referencias

📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.


Andrés Morales

Desarrollador e investigador en inteligencia artificial. Escribe sobre modelos de lenguaje, frameworks, herramientas para devs y lanzamientos open source. Cubre papers de ML, ecosistema de startups tech y tendencias de programación.

0 Comentarios

Deja un comentario

Marcador de posición del avatar

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.