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
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:
responder 200, existente # ya se procesó: devolvemos lo mismo
cobro = db.insertar_cobro(usuario.id, monto, clave) # clave UNIQUE en la tabla
proveedor.cobrar(usuario.tarjeta, monto)
responder 201, cobro
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.