Saltar al contenido
Open Security
Backend y ArquitecturaIntermedio· 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

Miralo en video

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

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