Idempotencia: por qué un reintento te cobra dos veces
La red falla, el cliente reintenta, y el usuario termina con dos cobros. El problema no es el retry: es que el endpoint no es idempotente. Acá ves la diferencia y el fix.
Antes de empezar necesitás
- Saber qué es una API HTTP y los métodos GET/POST/PUT
- Idea básica de timeouts y reintentos
Al terminar vas a poder
- Definir idempotencia y por qué importa bajo reintentos
- Saber qué métodos HTTP son idempotentes por contrato y cuáles no
- Diseñar un POST seguro con Idempotency-Key
- Reconocer el patrón en cobros, envío de mails y creación de recursos
Miralo en video
- Por qué tu API explota con 100 usuarios (workers y colas EXPLICADO FÁCIL)VT Security · YouTubeWorkers, colas, reintentos con backoff e idempotencia para no cobrar dos veces.
El cliente manda un cobro. El servidor lo procesa, pero la respuesta se pierde en la red: timeout. El cliente, que no sabe si funcionó, hace lo razonable: reintenta. Y el usuario aparece con dos cobros. El bug no está en el retry -el retry es correcto-. Está en que el endpoint no es idempotente.
El contrato de HTTP
Algunos métodos son idempotentes por definición; los clientes y proxies cuentan con eso para reintentar:
método idempotente si se reintenta...
GET sí devuelve lo mismo, no cambia estado
PUT sí deja el recurso en el mismo estado final
DELETE sí borrar lo ya borrado no hace más daño
POST NO crea/ejecuta de nuevo: duplica
El endpoint roto
Mirá este cobro. Es correcto… hasta el primer timeout:
POST /api/charges
función crear_cobro(request):
usuario = autenticar(request)
monto = request.body.monto
cobro = db.insertar_cobro(usuario.id, monto) # ← cada llamada inserta uno
proveedor.cobrar(usuario.tarjeta, monto)
responder 201, cobro
El cliente llama, el server cobra, la respuesta se pierde, el cliente reintenta, el server cobra de nuevo. Nada acá distingue “primer intento” de “reintento del mismo cobro”.
# El mismo cobro mandado dos veces (simulando un retry). Sin idempotencia: dos cargos.
curl -s -X POST https://api.example.com/api/charges \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"monto": 5000}'El fix: Idempotency-Key
La idea es que el cliente genere una clave única por intención (no por request) y la mande. El servidor recuerda esa clave: si ya la vio, devuelve el resultado guardado en vez de ejecutar otra vez.
POST /api/charges
Idempotency-Key: 7f3a9c2e-... ← el cliente la genera una vez por cobro
función crear_cobro(request):
usuario = autenticar(request)
clave = request.headers["Idempotency-Key"]
si clave es nula:
responder 400 # exigimos la clave en operaciones que cobran
existente = db.buscar_por_clave(clave)
si existente y existente.monto != monto:
responder 422 # misma clave, otro pedido: error del cliente
si existente:
responder 201, existente # ya se procesó: repetimos la respuesta original
cobro = db.insertar_cobro(usuario.id, monto, clave) # clave UNIQUE en la tabla
proveedor.cobrar(usuario.tarjeta, monto)
responder 201, cobro
Probalo en local
Con el repo clonado, corré npm run lab:check -- idempotencia. Un servidor
descartable en 127.0.0.1 recibe cuatro POST: el primer intento crea un ID
(201), el reintento con la misma clave repite esa misma respuesta (201, mismo
ID, con Idempotent-Replayed: true), reutilizar la clave con otro monto da 422
y una intención nueva crea otro ID. Copiá la salida y explicá por qué el
segundo POST no duplica el registro.
Los códigos siguen el borrador IETF del header Idempotency-Key: 422 cuando la
clave llega con otro contenido, y 409 cuando el primer pedido con esa clave
todavía se está procesando.
La demo usa un mapa en memoria: se pierde al reiniciar y no cobra nada. En un sistema real necesitás persistir la clave y coordinar el efecto con el proveedor de pagos; la restricción UNIQUE local no alcanza si la respuesta del proveedor se pierde después de cobrar.
Dónde aplica
No es solo cobros. Cualquier POST que cambie el mundo y pueda reintentarse:
operación riesgo sin idempotencia
crear cobro/pago doble cargo
enviar email/SMS el usuario recibe dos
crear orden pedido duplicado
publicar evento a cola el consumidor lo procesa dos veces
Lo que practicás en este lab
Llevátelo a tu repo si querés, pero no es obligatorio: es tu aprendizaje.
- Pseudocódigo del endpoint vulnerable y del idempotente
- Una tabla: método HTTP → ¿idempotente? → qué pasa si se reintenta
- Writeup de 2 líneas: qué request duplicada deja de hacer daño tras el fix
Reto
Tomá el endpoint de cobro de abajo y agregale idempotencia con una clave. Escribí en dos líneas qué pasa ahora cuando el cliente reintenta el mismo cobro tras un timeout.
Resolvelo y escribí dos líneas explicando qué pasó. Con eso lo fijás.