Módulo 4: Idempotencia

8. Proyecto: haz idempotente el `charge` de Mercado y pruébalo con reintentos

Descripción

Llegó el momento de juntar todo. A lo largo del módulo mediste el problema (el doble cargo, lección 2), construiste la key del cliente (lección 3) y el store del servidor (lección 4), aprendiste cuándo la idempotencia sale gratis (lección 5), cómo la codifica HTTP (lección 6) y por qué es inevitable (lección 7). Este proyecto los ensambla en un sistema que funciona: tomas la llamada cruda de orders a payments.charge —sin protección, la que en la flota de mil órdenes se sobrecobra $48,139— y la vuelves idempotente de punta a punta, midiendo el antes y el después hasta llegar a $0 de sobrecobro y a 3 de 3 pruebas de aceptación en verde.

No es un ejercicio de lectura: es el patrón completo, ejecutado y medido, que podrías llevarte a un sistema real. El entregable tiene tres partes, como todo capstone de esta guía: el código (el charge idempotente, cliente y servidor), la tabla de métricas antes/después con las dependencias fallando, y la justificación de qué decisión resuelve qué falla. Al terminar, tendrás una demostración numérica de que la idempotencia convierte un cobro que se duplica bajo reintentos en uno que cobra exactamente lo debido, pase lo que pase con la red.

Conexión con el módulo: esta es la síntesis. Cada lección aportó una pieza; aquí las ves encajar y trabajar juntas sobre el caso de Mercado. Es también el "después" que cierra el arco abierto en la lección 2: el mismo $48,139 de sobrecobro que mediste como problema, ahora reducido a $0. Y es la base sobre la que se paran los módulos siguientes: una vez que payments.charge es seguro de reintentar, el circuit breaker (módulo 5) y el bulkhead (módulo 6) pueden protegerlo sin miedo a que un reintento corrompa el ledger.

El punto de partida: el charge crudo

Este es el código que Mercado tiene hoy, y el que vamos a arreglar. orders llama a payments.charge con el retry del módulo 3, pero sin ninguna idempotencia. El servidor cobra cada vez que le llega una request.

# --- SERVIDOR: payments, version cruda (sin idempotencia) ---
class PaymentsServer:
    def __init__(self):
        self.ledger = []                       # cada cargo ejecutado deja una linea

    def charge(self, order_id, amount):
        charge_id = f"ch_{len(self.ledger) + 1:05d}"
        self.ledger.append((order_id, amount, charge_id))   # cobra SIEMPRE
        return {"charge_id": charge_id, "amount": amount, "status": "captured"}


# --- CLIENTE: orders, llamando con retry (modulo 3) ---
def charge_order(order_id, amount):
    return retry(lambda: payments.charge(order_id, amount))   # sin key

El defecto es exactamente el de la lección 2: cuando la respuesta se pierde, retry reintenta, la nueva request llega a payments, y charge —que cobra siempre— deja otro cargo en el ledger. Sobre la flota de mil órdenes con una tasa de pérdida del 15%, esto produce el "antes" que ya conoces: $305,118 cobrados cuando la intención era $256,979, un sobrecobro de $48,139.

La tarea

Vuelve idempotente el charge de Mercado, con estas condiciones:

  1. El cliente genera la idempotency_key una vez por operación, antes del primer intento, y la reenvía idéntica en cada reintento (lección 3). La key va fuera del retry.
  2. El servidor deduplica por key: si ya vio la key, devuelve el resultado guardado sin re-ejecutar; si es nueva, cobra y guarda el resultado completo (lección 4).
  3. La reserva de la key es atómica: dos reintentos concurrentes de la misma operación no pueden cobrar los dos (lección 4).
  4. Pruébalo con tres casos: 3 reintentos de la misma operación → 1 cargo; dos operaciones distintas del mismo monto → 2 cargos (no las fundas); una carrera concurrente → 1 cargo.
  5. Mide el antes y el después sobre la flota, con las dependencias fallando, y produce la tabla.

La solución: el charge idempotente

El servidor gana un store y la lógica de tres ramas de la lección 4, con la reserva de la key como primera acción (atómica).

class PaymentsServer:
    def __init__(self):
        self.ledger = []          # (key, amount, charge_id): cargos ejecutados
        self.store = {}           # idempotency_key -> resultado COMPLETO guardado

    def charge(self, idempotency_key, order_id, amount):
        # 1) BUSCAR / RESERVAR (atomico): ¿ya existe esta key?
        #    En una BD real: INSERT de la key con UNIQUE; si choca, es duplicado.
        existing = self.store.get(idempotency_key)
        if existing is not None:
            return existing                    # 2) DEVOLVER lo guardado (no re-ejecuta)

        # 3) EJECUTAR y GUARDAR: key nueva
        charge_id = f"ch_{len(self.ledger) + 1:05d}"
        self.ledger.append((idempotency_key, amount, charge_id))
        result = {"charge_id": charge_id, "order_id": order_id,
                  "amount": amount, "status": "captured"}
        self.store[idempotency_key] = result   # guarda el resultado completo
        return result

El cliente genera la key una vez y la reusa en todos los reintentos. La posición de la línea key = str(uuid.uuid4())fuera del lambda que retry reintenta— es lo que la hace estable a lo largo de los intentos (lección 3).

import uuid

def charge_order(order_id, amount):
    key = str(uuid.uuid4())                    # UNA vez, fuera del retry
    return retry(lambda: payments.charge(
        idempotency_key=key,                   # la MISMA key en cada reintento
        order_id=order_id,
        amount=amount,
    ))

Dos cambios, uno en cada lado: el servidor recuerda (store + tres ramas), el cliente aporta una key estable (una línea, fuera del retry). Con eso, el cobro se vuelve seguro de reintentar. En un sistema real, el self.store.get(...) / INSERT sería una fila en una base de datos con la idempotency_key como UNIQUE, y store[key] = result sería el UPDATE que la marca completed con el resultado —pero la lógica es exactamente esta—.

Ejemplo trabajado: el antes y el después, medido

Corremos las dos versiones sobre la misma flota de mil órdenes, con la misma semilla y la misma secuencia de pérdidas de red (15% de respuestas perdidas, hasta 4 intentos). La red falla igual en ambos mundos; lo único que cambia es la idempotencia. Salida real.

PROYECTO M4 — payments.charge idempotente  (flota N=1000, seed=42)
  Cobro correcto (intencion): $256,979   (1000 cargos)

  Metrica                      ANTES (crudo)    DESPUES (idem)
  ------------------------------------------------------------
  intentos al server                   1,184             1,184
  ejecuciones reales                   1,184             1,000
  cargos en el ledger                  1,184             1,000
  ordenes cobradas 2+ veces              146                 0
  TOTAL COBRADO                     $305,118          $256,979
  SOBRECOBRO                         $48,139                $0
    (% sobre intencion)                18.7%              0.0%

Qué esperar. El total cobrado es el veredicto: $305,118 antes, $256,979 después —exactamente la intención—.

Los intentos son idénticos (1.184). orders reintentó igual en ambos mundos: la política de reintentos es del cliente y no cambió. La red perdió las mismas respuestas, retry disparó los mismos 184 reintentos sobre los 1.000 cobros base. Esto es clave: no arreglamos el problema haciendo que el cliente reintente menos —reintenta exactamente igual de agresivo—; lo arreglamos en el servidor.

Las ejecuciones reales bajan de 1.184 a 1.000. Antes, cada uno de los 1.184 intentos ejecutó el cobro (el servidor sin memoria). Después, solo 1.000 intentos ejecutaron —uno por orden—; los otros 184 fueron reintentos que el servidor reconoció por su key y devolvió sin cobrar. Los 184 reintentos que antes eran 184 cargos de más, ahora son 184 replays inofensivos.

El sobrecobro pasa de $48,139 (18.7%) a $0. Antes, 146 de las mil órdenes se cobraron dos o más veces, y el ledger sumó $48,139 de más. Después, cero órdenes se cobraron de más y el total iguala la intención al centavo. Dos cambios de código —el store del servidor, la key del cliente— convirtieron un 18.7% de sobrecobro en un 0%, sin tocar la política de reintentos. Ese es el proyecto: la diferencia entre $305,118 y $256,979 es toda idempotencia.

Las pruebas de aceptación

Un capstone no está terminado sin pruebas que demuestren las propiedades. Estas tres cubren los casos que el módulo enseñó a cuidar, y las tres pasan.

PRUEBAS DE ACEPTACION
  [PASS] 3 reintentos misma key    -> 1 cargo, $500   (cargos=1, total=$500)
  [PASS] 2 operaciones distintas   -> 2 cargos, $1000 (cargos=2, total=$1000)
  [PASS] carrera concurrente       -> 1 cargo, $500   (cargos=1, total=$500)

  Resultado: 3/3 pruebas en verde

Test 1 — 3 reintentos, 1 cargo. La misma operación (misma key) reintentada 3 veces deja 1 cargo, $500. Es la propiedad central: el total cobrado se desacopla del número de reintentos (lección 3). Este test atrapa el bug de la key-por-intento —si la key se generara dentro del retry, este test daría 3 cargos y fallaría—.

Test 2 — 2 operaciones distintas, 2 cargos. Dos cobros legítimos distintos del mismo monto ($500 cada uno) con keys distintas dejan 2 cargos, $1000. Es la propiedad simétrica: la idempotencia no funde operaciones distintas (lección 3, el alcance de la key). Este test atrapa una key demasiado amplia —si la key fuera por cliente o por monto, este test daría 1 cargo y perdería un cobro—.

Test 3 — carrera concurrente, 1 cargo. Dos reintentos de la misma operación que llegan a la vez dejan 1 cargo, $500, porque la reserva de la key es atómica (lección 4). Este test atrapa el check-then-act no atómico —sin la reserva atómica, los dos reintentos concurrentes cobrarían y este test daría 2 cargos—.

Las tres pruebas juntas verifican que la solución es correcta en las tres dimensiones que importan: reintentos secuenciales (test 1), separación de operaciones (test 2) y concurrencia (test 3). Un charge que pasa las tres es seguro de reintentar bajo cualquier combinación de fallas de red.

La justificación: qué decisión resuelve qué falla

El entregable incluye explicar por qué cada pieza está ahí. Esta es la tabla de trazabilidad —de la falla al remedio— que acompaña al código.

Falla / riesgoDecisión que lo resuelveLección
La respuesta se pierde y orders reintenta un cobro que ya ocurrió → doble cargoLa idempotency_key deja que el servidor reconozca el reintento como la misma operación2, 3
El cliente no puede saber si el cobro ocurrió (info en el servidor)La corrección vive en el servidor (store), con la key que el cliente aporta1, 4
Un reintento genera una key nueva y rompe la dedupLa key se genera una vez, fuera del retry, y se reusa idéntica3
Dos operaciones distintas se funden en una keyLa key tiene alcance "una operación" (UUID por operación)3
Dos reintentos concurrentes cobran los dosReserva atómica de la key (INSERT con UNIQUE)4
El reintento no recibe la confirmación que se perdióEl servidor guarda el resultado completo y lo devuelve en el replay4
Perseguir "exactly-once de entrega" (imposible)at-least-once (retry) + idempotencia (key) = efecto exactly-once7

La lógica de la tabla: cada fila es una forma concreta en que el cobro podía fallar bajo reintentos, y la columna del medio es la pieza del módulo que la neutraliza. Leerla de arriba abajo es reconstruir el módulo entero como una cadena de decisiones, cada una motivada por una falla medida. Esa es la diferencia entre "le puse idempotencia porque me dijeron" y "cada línea del charge idempotente responde a una falla específica que sé nombrar y medir".

Extensiones (opcionales, para ir más lejos)

Si quieres empujar el proyecto más allá del mínimo:

  • TTL de las keys. Agrega un tiempo de vida al store (Stripe usa 24 horas) y mide cuánto crece el store con y sin TTL sobre un día de tráfico simulado. Verifica que un reintento dentro del TTL sigue deduplicando.
  • Estado in-progress. Extiende el store a los dos estados (in-progress/completed) y simula un reintento que llega mientras el primer cobro todavía corre; verifica que el reintento espera o recibe "en proceso" en vez de cobrar (lección 4).
  • Key derivada vs UUID. Cambia la key de uuid.uuid4() a f"charge:{order_id}" y comprueba que las pruebas siguen en verde; discute en qué caso cada opción es preferible (lección 3).
  • Combínalo con el módulo 3. Mete el charge idempotente dentro del retry con backoff+jitter del módulo 3 y mide las dos propiedades a la vez: que los reintentos no tumban a payments (módulo 3) y que no cobran dos veces (módulo 4). Es el anticipo del capstone final de la guía (módulo 8).

Errores comunes

Declarar victoria con la tabla del "después" sin las pruebas. Qué pasa: se ve el sobrecobro en $0 sobre la flota y se da el trabajo por terminado. Por qué pasa: la tabla agregada se ve convincente. Cómo detectarlo: la flota mide el caso promedio, pero puede ocultar bugs de borde —una key mal generada podría dar $0 de sobrecobro en la flota por casualidad de la semilla, y fallar en el test de 3 reintentos—. Cómo corregirlo: las tres pruebas de aceptación verifican las propiedades directamente (reintentos, separación, concurrencia), no en promedio. Un capstone entrega la tabla y las pruebas en verde; la tabla convence, las pruebas garantizan.

Olvidar el test de "operaciones distintas no se funden". Qué pasa: se prueba que los reintentos no duplican (test 1) pero no que operaciones legítimas distintas sí cobran (test 2). Por qué pasa: el foco natural es el doble cargo, no el cobro perdido. Cómo detectarlo: una key demasiado amplia pasaría el test 1 (no duplica) pero fundiría cobros distintos —un bug que solo el test 2 atrapa—. Cómo corregirlo: probar siempre las dos direcciones: que lo que debe ser lo mismo se deduplica (test 1) y que lo que debe ser distinto se separa (test 2). La idempotencia mal calibrada falla en cualquiera de las dos direcciones.

Entregar el código sin la justificación. Qué pasa: se entrega el charge idempotente funcionando, pero sin explicar por qué cada pieza está ahí. Por qué pasa: el código "habla por sí mismo", se cree. Cómo detectarlo: si no puedes trazar cada decisión (la key fuera del retry, la reserva atómica, el resultado completo) a una falla concreta que resuelve, no entiendes del todo tu propia solución —y el próximo que la toque romperá una pieza sin saber qué protegía—. Cómo corregirlo: acompaña el código con la tabla de trazabilidad falla→remedio. La justificación es parte del entregable, no un extra; es lo que convierte el código en conocimiento transferible.

Ejercicios

Ejercicio 1 — Rompe una pieza, predice el test que falla. Para cada cambio al charge idempotente, predice cuál de las tres pruebas de aceptación pasaría a fallar y por qué. (a) Mover key = str(uuid.uuid4()) dentro del lambda del retry. (b) Cambiar la key a f"charge:{amount}". (c) Quitar la reserva atómica y hacer un check-then-write en dos pasos.

Ver solución
  • (a) Key dentro del lambda → falla el Test 1 (3 reintentos → 1 cargo). Cada reintento generaría una key nueva, así que los 3 intentos llegarían con 3 keys distintas y el servidor cobraría las 3 veces: el test daría 3 cargos / $1500 en vez de 1 / $500. (La flota también se sobrecobraría, volviendo al "antes".) Es el bug de la lección 3.
  • (b) Key f"charge:{amount}" → falla el Test 2 (2 operaciones distintas → 2 cargos). Dos operaciones distintas del mismo monto ($500) generarían la misma key charge:500, así que el servidor fundiría la segunda con la primera y cobraría 1 sola vez: el test daría 1 cargo / $500 en vez de 2 / $1000. Un cobro legítimo se perdería. Es la key demasiado amplia de la lección 3. (El Test 1 seguiría pasando, lo que muestra por qué hacen falta los dos.)
  • (c) check-then-write en dos pasos → falla el Test 3 (carrera concurrente → 1 cargo). Los dos reintentos concurrentes pasarían la búsqueda antes de que cualquiera guardara, y los dos cobrarían: el test daría 2 cargos / $1000 en vez de 1 / $500. Es la condición de carrera de la lección 4. (Los tests 1 y 2, que son secuenciales, seguirían pasando —por eso la concurrencia necesita su propio test—.)

Cada prueba protege una propiedad distinta, y cada bug del módulo rompe exactamente una. Por eso las tres son necesarias: ninguna cubre lo de las otras.

Ejercicio 2 — El "antes" a otra tasa de pérdida. El sobrecobro del "antes" fue 18.7% con una tasa de pérdida del 15%. Sin correr nada, razona cualitativamente qué le pasaría al sobrecobro del "antes" y al del "después" si la tasa de pérdida subiera al 30%. ¿Cuál cambia y cuál no?

Ver solución

El sobrecobro del "antes" subiría (más allá del 18.7%). Con una tasa de pérdida más alta, más respuestas se pierden, así que orders reintenta más, y sin idempotencia cada reintento es un cargo de más. Más reintentos → más cargos duplicados → mayor sobrecobro. El daño del cobro crudo es proporcional a la tasa de pérdida de la red: peor red, peor sobrecobro.

El sobrecobro del "después" seguiría en $0. La idempotencia desacopla el total cobrado del número de reintentos: no importa cuántas veces se reintente cada orden, el servidor cobra una sola vez por key. Con 30% de pérdida habría más reintentos (más intentos totales al server, más replays), pero todos esos reintentos extra caerían en la rama "ya vista" y no cobrarían. El sobrecobro sería $0 con 15%, con 30% o con 90% de pérdida.

Esta es la propiedad que hace valiosa la idempotencia: convierte el sobrecobro de "proporcional a las fallas de red" (que no controlas) a "cero, pase lo que pase con la red". El "antes" te ata a la suerte de la red; el "después" te independiza de ella. Es la diferencia entre esperar que la red no falle (que el módulo 1 te enseñó a no creer) y que la falla de la red no importe.

Ejercicio 3 — El puente al módulo 5. Ya tienes payments.charge idempotente. El módulo 5 (circuit breakers) va a dejar de llamar a un servicio que está muerto. Explica por qué la idempotencia de este módulo es un prerrequisito para que el circuit breaker pueda actuar con seguridad sobre un cobro, y qué pasaría si el breaker reintentara un cobro no idempotente al recuperarse.

Ver solución

Un circuit breaker, al recuperarse (pasar de OPEN a HALF_OPEN), reenvía llamadas de prueba al servicio para ver si ya volvió. Si esas llamadas de prueba son cobros, y el cobro no fuera idempotente, cada intento de prueba del breaker podría cobrar —y peor: durante el periodo en que el breaker estuvo OPEN, es posible que algunos cobros hayan llegado a payments justo antes de abrirse, con respuestas perdidas; al recuperarse y reintentar, sin idempotencia esos cobros se duplicarían—. El breaker, que existe para proteger, se volvería una fuente de dobles cargos.

La idempotencia de este módulo es el prerrequisito que vuelve seguro ese comportamiento: como charge deduplica por key, las llamadas de prueba del breaker y cualquier reintento al recuperarse no cobran de más —caen en la rama "ya vista" si la operación ya se procesó—. El breaker puede sondear y reintentar con total libertad, porque el charge idempotente garantiza que ningún reintento, venga de donde venga (el retry del módulo 3, la recuperación del breaker del módulo 5), duplica el cargo.

Esta es la razón por la que el módulo 4 va antes que el 5 en la guía: los patrones que reintentan o resondean (retry, circuit breaker) solo son seguros sobre operaciones idempotentes. La idempotencia es el cimiento que vuelve seguro reintentar; el resto de los patrones de resiliencia se paran sobre él cuando tocan operaciones que mutan estado.

Resumen y siguiente paso

En este proyecto ensamblaste la idempotencia del cobro de Mercado de punta a punta. Del lado del cliente, una idempotency_key generada una vez por operación —fuera del retry— y reusada en cada reintento. Del lado del servidor, un store que busca por key, devuelve el resultado guardado si ya la vio, y ejecuta-y-guarda si es nueva —con la reserva de la key atómica—. Dos cambios de código, uno en cada lado.

Mediste el antes y el después sobre la flota de mil órdenes, con la red fallando igual en ambos: el sobrecobro pasó de $48,139 (18.7%, 146 órdenes duplicadas) a $0 (cero órdenes duplicadas), sin cambiar la política de reintentos —los 1.184 intentos fueron idénticos; lo que cambió es que las ejecuciones reales bajaron de 1.184 a 1.000—. Y verificaste las tres propiedades con pruebas de aceptación en verde: 3 reintentos → 1 cargo, 2 operaciones distintas → 2 cargos, carrera concurrente → 1 cargo. La tabla de trazabilidad falla→remedio justifica cada decisión.

Con esto cierras el módulo 4. Sabes tomar una operación mutante y peligrosa —un cobro— y volverla segura de reintentar, con la key del cliente y el store atómico del servidor, y demostrarlo con números.

Lo que sigue es el módulo 5: circuit breakers. Hasta ahora, cuando una dependencia falla, la reintentamos (módulo 3) de forma segura (módulo 4). Pero, ¿qué pasa cuando la dependencia no está lenta ni pierde respuestas, sino muerta —caída del todo, y así seguirá por minutos—? Seguir llamándola (y reintentando, aunque sea idempotente) es desperdiciar tiempo y recursos golpeando una puerta que no se va a abrir. El circuit breaker aprende a dejar de llamar a un servicio que sabemos muerto, para ni siquiera pagar el costo del timeout, y a resondear con cuidado para detectar cuándo vuelve. Y —como viste en el ejercicio 3— solo es seguro porque el cobro que reintenta al recuperarse ya es idempotente. El cimiento que pusiste aquí es lo que deja al breaker actuar sin miedo.

Recursos

  • Brandur Leach, "Designing robust and predictable APIs with idempotency", Stripe Blog — stripe.com/blog/idempotency. El diseño completo de un cobro idempotente en producción: la key del cliente, el store transaccional, los estados de la operación. La referencia de arquitectura que este proyecto reproduce a escala de simulación. Gratis y en inglés.
  • Documentación de AWS Lambda Powertools (Python): "Idempotency" — docs.powertools.aws.dev/lambda/python/latest/utilities/idempotency. Una implementación real y open source del charge idempotente de este proyecto: store con estados, TTL, reserva atómica. Útil para ver cómo se ve el código de producción del store que aquí simulaste. En inglés.
  • Documentación de la API de Stripe: "Idempotent requests" — docs.stripe.com/api/idempotent_requests. El contrato visto desde el cliente: cómo mandar la Idempotency-Key, qué devuelve un reintento, el TTL de 24 horas. La API que tu charge_order imita. En inglés.
  • Michael T. Nygard, Release It!, 2ª ed. (Pragmatic Bookshelf, 2018) — el marco de los patrones de estabilidad; ubica la idempotencia como el prerrequisito de los patrones que reintentan (retry) y resondean (circuit breaker), el puente al módulo 5. En inglés.