Saltar al contenido
Open Security
Backend y Arquitectura Intermedio · 30 min

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.

#backend#apis#idempotencia

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”.

vt@labs:~
# 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.

¿Hiciste el lab?

Si querés, guardá lo que hiciste (comandos, notas, un repo) para volver después. Y si encontrás un error o querés mejorar este lab, contribuí al repo. El progreso se guarda solo en tu navegador.