Módulo 4: Idempotencia
2. El doble cargo del reintento, medido
Descripción
La lección 1 hizo una afirmación fuerte: un reintento de un cobro, sin protección, cobra dos veces. Es hora de dejar de afirmarlo y medirlo. En esta lección montamos el escenario exacto de Mercado —orders llamando a payments.charge, la respuesta perdida en la red, el reintento del módulo 3— como una simulación determinista en Python, la corremos con y sin idempotencia, y ponemos los totales cobrados lado a lado. No vas a tener que creer en el mecanismo del doble cargo; lo vas a ver en el ledger.
El resultado, adelantado en una frase, es tan simple como brutal: sin idempotencia, un comprador que debía pagar $500 paga $1000; y una flota de mil órdenes que debía cobrar $256,979 cobra $305,118 —un sobrecobro de $48,139, el 18.7% de más—. Con idempotency_key, el mismo comprador paga $500 y la misma flota cobra exactamente $256,979: cero de sobrecobro. Lo único que cambia entre los dos mundos es que el cobro lleva una key y el servidor la recuerda. La red falla igual en ambos —las mismas respuestas se pierden, los mismos reintentos ocurren—; la diferencia es que en uno cada reintento deja un cargo nuevo y en el otro el servidor lo absorbe.
Conexión con el módulo: esta es la lección medida, el corazón del módulo. La lección 1 dio el mecanismo (la respuesta perdida, el reintento que llega después del efecto); aquí ejecutamos el fenómeno completo y lo cuantificamos con un simulador que puedes correr tú mismo. Las lecciones que siguen construyen la cura —la idempotency_key del lado del cliente (3), la deduplicación del lado del servidor (4)—, pero la evidencia de por qué hace falta esa cura está toda aquí. El número que produce esta simulación —los $48,139 de sobrecobro— es el "antes" contra el cual medirás tu "después" en el proyecto final (lección 8).
El cajero sin memoria: la analogía del ticket
Imagina un cajero que cobra en efectivo y no recuerda a nadie. Cada persona que se le para enfrente es, para él, un cliente nuevo. Llega Ana, le dice "cóbrame $500", el cajero cobra y le entrega un recibo. Pero justo cuando le iba a pasar el recibo, un ruido los distrae y Ana no lo recibe. Ana, que no sabe si le cobraron, vuelve a la fila y le dice otra vez "cóbrame $500". El cajero —que no la recuerda— la ve como una clienta nueva con una compra nueva, y cobra otra vez. Ana pagó $1000. El cajero no hizo nada "mal" según sus reglas: cada vez que alguien pide un cobro, cobra. El problema es que no tiene forma de reconocer que la segunda vez era la misma compra de Ana otra vez, no una compra nueva.
Ahora dale al cajero un cuaderno y una regla: "cuando alguien te pida un cobro, que te dé primero su número de folio; antes de cobrar, revisa el cuaderno; si ese folio ya está anotado como cobrado, no cobres —solo entrégale de nuevo su recibo—; si no está, cobra y anótalo". Ana llega con el folio F-9001. El cajero revisa: no está, cobra $500, anota "F-9001: cobrado, recibo #ch_0001". Ana vuelve con el mismo folio F-9001 porque no recibió el recibo. El cajero revisa el cuaderno: F-9001 ya está —"no cobro de nuevo, aquí tienes tu recibo #ch_0001"—. Ana pagó $500. El folio es la idempotency_key, el cuaderno es el store de idempotencia del servidor, y "revisar antes de cobrar" es la deduplicación. En esta lección medimos el cajero sin cuaderno (el doble cargo) y el cajero con cuaderno (el cargo único), para que la diferencia sea un número y no una intuición.
El simulador: un ledger, una respuesta que se pierde, un reintento
Modelamos payments como un servidor con dos piezas: un ledger append-only (cada cargo ejecutado deja una línea; el total cobrado es la suma) y un store de idempotencia (un diccionario idempotency_key → resultado, que solo se usa en el modo idempotente). El corazón es el método charge: en modo idempotente, si ya vio la key, devuelve el resultado guardado sin tocar el ledger; si no, ejecuta el cobro y —si es idempotente— lo recuerda.
class PaymentsServer:
def __init__(self, idempotent):
self.idempotent = idempotent
self.ledger = [] # cargos ejecutados: (key, amount, charge_id)
self.store = {} # idempotency_key -> resultado guardado
def charge(self, idempotency_key, order_id, amount):
# Camino idempotente: si ya vimos esta key, devolvemos lo guardado
# SIN volver a tocar el ledger (no re-ejecuta el cobro).
if self.idempotent and idempotency_key in self.store:
return self.store[idempotency_key] # replay
# Ejecuta el cobro de verdad: escribe en el ledger.
charge_id = f"ch_{len(self.ledger) + 1:04d}"
self.ledger.append((idempotency_key, amount, charge_id))
result = {"charge_id": charge_id, "amount": amount, "status": "captured"}
if self.idempotent:
self.store[idempotency_key] = result # recuerda para el retry
return result
def total_charged(self):
return sum(amount for (_key, amount, _cid) in self.ledger)
El cliente (orders) reintenta cuando la respuesta se pierde. Modelamos la pérdida de la respuesta: el servidor ya cobró (la línea server.charge(...) ya corrió y escribió el ledger), pero con probabilidad p_response_lost la confirmación no llega y el cliente reintenta.
def client_charge(server, rng, order_id, amount, idempotency_key,
p_response_lost, max_attempts):
attempts = 0
while attempts < max_attempts:
attempts += 1
result = server.charge(idempotency_key, order_id, amount) # el server YA cobro
if rng.random() >= p_response_lost:
return attempts, result # la respuesta llego, terminamos
# la respuesta se perdio -> el bucle reintenta (el cobro ya ocurrio)
return attempts, None # se agotaron los reintentos sin confirmacion
Fíjate en el orden dentro del bucle, porque es donde vive el doble cargo: primero se llama a server.charge (que ejecuta el cobro), y después se decide si la respuesta se perdió. Si se perdió, el bucle da otra vuelta y vuelve a llamar a server.charge —un segundo cobro—. En el modo no idempotente, esa segunda llamada escribe otra línea en el ledger. En el modo idempotente, la misma key ya está en el store, así que devuelve lo guardado y el ledger no crece. Esa es toda la diferencia.
Ejemplo trabajado I: un comprador
Corremos el escenario de Ana: $500, la respuesta del primer intento se pierde, orders reintenta una vez. Con y sin idempotencia, misma semilla (42). Esta es la salida real.
ESCENARIO A · Un comprador, la respuesta se pierde, 1 reintento
Ana compra por $500. El cobro llega a payments y se ejecuta,
pero la respuesta se pierde en la red. orders reintenta.
SIN idempotencia -> reintentos=2 cargos=2 TOTAL COBRADO=$1000
CON idempotency_key -> reintentos=2 cargos=1 TOTAL COBRADO=$500
Cobro correcto esperado: $500
Qué esperar. El total cobrado es el veredicto, y aquí dice $1000 contra $500.
Sin idempotencia, los dos intentos de orders dejaron dos cargos en el ledger. El primero cobró y su respuesta se perdió; el segundo —el reintento— llegó a un payments que no recuerda nada, lo trató como un cobro nuevo, y cobró otra vez. Dos líneas en el ledger, $500 cada una: $1000. Ana pidió pagar una vez y pagó dos.
Con idempotency_key, orders reintentó exactamente igual —dos intentos, porque la respuesta del primero se perdió igual que antes—, pero el segundo intento llegó con la misma key que el primero. payments la reconoció en su store, devolvió el resultado guardado (ch_0001) y no escribió una segunda línea. Un cargo, $500. El reintento fue idéntico en los dos mundos; lo que cambió fue que el servidor tenía memoria. Ese es el módulo entero en dos números: $1000 sin memoria, $500 con memoria.
Ejemplo trabajado II: la flota
Un comprador convence, pero puede sonar a caso extremo. Subamos a escala real: mil órdenes, cada una con un monto entre $10 y $500 (fijado por semilla, idéntico en ambos mundos), una probabilidad del 15% de que cada respuesta se pierda, y el reintento del módulo 3 con hasta 4 intentos. La misma secuencia de pérdidas ocurre en los dos mundos —la red falla igual—, así que la única variable es la idempotencia.
ESCENARIO C · Flota de 1000 ordenes con perdidas de respuesta
N=1000 ordenes | p(respuesta perdida)=0.15 | max_attempts=4
Cobro correcto (intencion) : $ 256,979 (1000 cargos)
SIN idempotencia:
intentos totales al server : 1,184
cargos en el ledger : 1,184
ordenes cobradas 2+ veces : 146
TOTAL COBRADO : $ 305,118
SOBRECOBRO : $ 48,139 (18.7% de mas)
CON idempotency_key:
intentos totales al server : 1,184
ejecuciones reales del cobro : 1,000
cargos en el ledger : 1,000
ordenes cobradas 2+ veces : 0
TOTAL COBRADO : $ 256,979
SOBRECOBRO : $ 0
Qué esperar. Léelo por partes, porque cada número cuenta un pedazo de la historia.
Los intentos son idénticos; los cargos no. En los dos mundos, orders mandó exactamente 1.184 intentos al servidor —mil órdenes más 184 reintentos causados por respuestas perdidas—. Esa cifra es la misma porque la red falló igual: mismos reintentos, misma carga. La diferencia está en qué hizo el servidor con ellos. Sin idempotencia, los 1.184 intentos dejaron 1.184 cargos (cada intento cobra). Con idempotencia, los 1.184 intentos produjeron solo 1.000 ejecuciones reales —los otros 184 fueron reintentos que el servidor reconoció por su key y devolvió sin cobrar—, así que el ledger tiene 1.000 cargos, uno por orden. Los 184 reintentos que en un mundo son dinero de más, en el otro son replays inofensivos.
El sobrecobro es real y grande. Sin idempotencia, el ledger suma $305,118 cuando la intención era $256,979: $48,139 de más, el 18.7%. No es un redondeo ni un caso de borde: casi uno de cada cinco dólares cobrados no debía cobrarse. Y 146 de las mil órdenes se cobraron dos o más veces —146 compradores reales a los que se les cobró de más, cada uno un reembolso, un ticket, quizás un contracargo—. Con idempotency_key, el sobrecobro es exactamente $0: el total cobrado iguala la intención al centavo, y cero órdenes se cobraron de más.
La proporción escala con la tasa de pérdida, no desaparece. El 18.7% salió de una tasa de pérdida del 15%; con una red más sana (1% de pérdida) el porcentaje baja, pero nunca llega a cero mientras haya una respuesta perdida, y multiplicado por un volumen real sigue siendo mucho dinero (lo calculaste en el ejercicio 2 de la lección 1). El punto no es el número exacto —depende de tu red—, es que sin idempotencia el sobrecobro es proporcional a tus fallas de red, y con idempotencia es cero pase lo que pase con la red. Esa es la diferencia entre "esperemos que la red no falle" (que el módulo 1 ya te enseñó a no creer) y "la red puede fallar y no importa".
Por qué el cliente no puede arreglar esto solo, visto en los números
La tentación, viendo los 184 reintentos "de más", es decir: "que orders reintente menos". Pero mira qué pasaría. Esos 184 reintentos no fueron caprichos; cada uno ocurrió porque una respuesta se perdió y orders no supo si el cobro había ocurrido. Si le dijeras a orders "no reintentes cuando dudes", esos 184 casos se dividirían en dos: los que de verdad se cobraron (donde el reintento habría sido un doble cargo) y los que de verdad se perdieron en la ida (donde no reintentar deja al comprador sin pagar y el pedido sin cobrar). orders no puede distinguir cuál es cuál —la información de si se cobró vive en el ledger de payments, no en orders—, así que cualquier regla del cliente falla en una mitad de los casos.
La simulación lo hace visible: en el mundo idempotente, orders mandó los mismos 1.184 intentos —reintentó igual de agresivo— y aun así el sobrecobro fue cero. No hizo falta que el cliente fuera más cuidadoso; hizo falta que el servidor tuviera memoria. Esa es la lección arquitectónica de todo el módulo: la corrección se pone en el servidor, con la key que el cliente aporta, no en la política de reintentos del cliente. El cliente reintenta con la libertad que le dio el módulo 3; el servidor absorbe la duplicación con la idempotencia que le da este módulo.
Errores comunes
Medir el éxito por "no hubo errores" en vez de por el total cobrado. Qué pasa: el equipo mira sus tableros, ve que todas las requests de cobro devolvieron 200, y concluye que el cobro está sano. Por qué pasa: un doble cargo no es un error —las dos llamadas tuvieron éxito—; es demasiado éxito. Cómo detectarlo: el doble cargo no aparece en la tasa de errores; aparece en una reconciliación entre "lo que se debía cobrar" y "lo que dice el ledger". Sin esa reconciliación, los $48,139 de sobrecobro son invisibles en los tableros de disponibilidad. Cómo corregirlo: vigila el total cobrado contra la intención (o el conteo de cargos por orden), no solo la tasa de éxito. La métrica que revela el doble cargo es "órdenes con 2+ cargos", y esa métrica solo existe si la construyes.
Confiar en que "la red de producción casi no pierde respuestas". Qué pasa: se descarta la idempotencia porque en las pruebas locales nunca se pierde una respuesta y en producción "la red es buena". Por qué pasa: la pérdida de respuestas no es solo cables rotos; un timeout del cliente cuenta como respuesta perdida aunque el servidor haya respondido a tiempo (el cliente cortó antes), y los timeouts son comunes bajo carga —justo cuando más cobros hay—. Cómo detectarlo: cada timeout del módulo 2, cada retry del módulo 3, es una respuesta que el cliente trata como perdida. Los patrones que ya aprendiste generan exactamente el escenario de esta lección. Cómo corregirlo: asumir que las respuestas se pierden —por red o por timeout— es la postura por defecto en un sistema distribuido; la idempotencia es la respuesta a esa certeza, no a un caso raro.
Deduplicar por el contenido de la request en vez de por una key explícita. Qué pasa: para evitar dobles cargos sin pedirle una key al cliente, alguien intenta detectar duplicados comparando "¿ya hubo un cobro de este order_id por este amount en los últimos N segundos?". Por qué pasa: parece que se puede inferir el duplicado del contenido. Cómo detectarlo: falla en los dos sentidos —rechaza cobros legítimos (un comprador que de verdad quiere pagar dos veces $500 al mismo pedido, o dos pedidos idénticos) y no siempre atrapa los duplicados reales (si el amount cambió por un ajuste)—. La "misma operación" no está definida por su contenido, sino por la intención del cliente. Cómo corregirlo: usar una idempotency_key explícita que el cliente genera una vez por operación (lección 3); es la única forma de que "el mismo cobro otra vez" y "un cobro nuevo que casualmente es igual" sean distinguibles.
Ejercicios
Ejercicio 1 — Predice el triple cargo. En el escenario de un comprador, la respuesta se perdió una vez y hubo 2 intentos → 2 cargos sin idempotencia. Sin correr nada, predice el total cobrado sin idempotencia si las respuestas de los dos primeros intentos se pierden y solo la del tercero llega. ¿Y con idempotency_key?
Ver solución
Sin idempotencia: 3 cargos, $1500. Si las respuestas de los intentos 1 y 2 se pierden, orders reintenta dos veces, para un total de 3 intentos. Cada intento llega al servidor y cobra (el servidor no recuerda nada), así que el ledger acumula 3 líneas de $500: $1500. El total cobrado crece linealmente con el número de intentos: N intentos, N cargos.
Con idempotency_key: 1 cargo, $500. Los 3 intentos llegan con la misma key. El primero cobra y guarda el resultado en el store; el segundo y el tercero encuentran la key ya guardada y devuelven el resultado sin cobrar. El ledger tiene 1 línea: $500. El total cobrado es independiente del número de intentos: 1 cargo pase lo que pase con la red. (Esto es exactamente el escenario B de la simulación, y lo mediremos en la lección 3.)
Ejercicio 2 — De dónde salen los 1.184. En la flota de mil órdenes, el servidor recibió 1.184 intentos. Explica de dónde salen los 184 extra sobre los 1.000 esperados, y por qué ese número es el mismo en el mundo con y sin idempotencia.
Ver solución
Los 1.000 base son un intento por orden (el primero, obligatorio). Los 184 extra son reintentos: cada vez que la respuesta de un intento se perdió (probabilidad 15% por intento), orders reintentó, y ese reintento es una request más al servidor. Con una tasa de pérdida del 15% y hasta 4 intentos, la suma de esos reintentos sobre las mil órdenes da 184 (algunas órdenes tuvieron 0 reintentos, otras 1, unas pocas 2 o 3).
Es el mismo número en ambos mundos porque la decisión de reintentar la toma el cliente, y el cliente reintenta según si recibió respuesta —no según si el servidor es idempotente—. La idempotencia vive en el servidor y no cambia la política del cliente: orders pierde las mismas respuestas y reintenta las mismas veces con o sin idempotencia. Lo que cambia es qué hace el servidor con esos 184 reintentos: sin idempotencia los convierte en 184 cargos de más; con idempotencia los reconoce por su key y los devuelve sin cobrar. Los intentos son idénticos (1.184); los cargos no (1.184 vs 1.000).
Ejercicio 3 — La reconciliación. Un equipo no tiene idempotencia y no quiere agregarla "todavía". Como parche, propone un job nocturno que reconcilia el ledger contra las órdenes y reembolsa los cargos duplicados. Nombra dos razones por las que este parche es peor que la idempotencia, aunque "arregle el dinero".
Ver solución
El job reembolsa los $48,139 de sobrecobro cada noche, así que "el dinero cuadra" al final del día. Pero es peor que la idempotencia por al menos dos razones:
-
El comprador ya vio el doble cargo. Entre el cobro duplicado y el reembolso nocturno pasan horas. En ese tiempo, el comprador ve
$1000en su estado de cuenta cuando pidió pagar$500, quizás se queda sin saldo para otra compra, llama a soporte, o disputa el cargo con su banco (un contracargo, que penaliza a la pasarela). El reembolso posterior no borra esa experiencia; el daño a la confianza ya ocurrió. La idempotencia evita el doble cargo antes de que suceda; el job lo cura después, cuando ya se vio. -
Es frágil y adivina la intención. El job tiene que decidir qué cargos son "duplicados" y cuáles son cobros legítimos repetidos (un comprador que de verdad pagó dos veces el mismo monto al mismo pedido). Sin una
idempotency_key, esa decisión es una heurística que se equivoca en los dos sentidos: reembolsa cobros buenos o deja pasar duplicados. La idempotencia no adivina: el cliente declaró la intención con la key, y el servidor la respetó exactamente.
Un tercer motivo: el job es trabajo operativo permanente (código que mantener, un proceso que puede fallar, alertas que atender) para un problema que la idempotencia resuelve de raíz con una key. El parche trata el síntoma; la idempotencia elimina la causa.
De medir el daño a construir la cura
Ya no hay que creer en el doble cargo: lo mediste. Sin idempotencia, un comprador paga $1000 por una compra de $500, y una flota de mil órdenes se sobrecobra $48,139 —el 18.7%, con 146 compradores afectados—. Con idempotency_key, el mismo comprador paga $500 y la flota cobra exactamente su intención, cero de más, aunque orders reintente igual de agresivo. Viste que la métrica reveladora es el total cobrado contra la intención (no la tasa de éxito), que el cliente no puede arreglarlo solo (no tiene la información), y que la corrección vive en el servidor con la key que el cliente aporta.
Lo que sigue es construir esa cura, y tiene dos mitades que las próximas dos lecciones separan a propósito. La lección 3 es la mitad del cliente: qué es exactamente una idempotency_key, quién la genera, cuándo, y por qué generarla mal —una key nueva por reintento— vuelve inútil toda la maquinaria del servidor (lo medirás: la misma operación cobra $1500 con la key mal generada). La lección 4 es la mitad del servidor: cómo guarda el resultado por key, cómo lo devuelve en el reintento, y por qué esa escritura tiene que ser atómica para que dos reintentos concurrentes no se cuelen los dos. Primero la key; después la memoria que la usa.
Resumen y siguiente paso
En esta lección mediste el doble cargo que la lección 1 solo describía. Con un simulador determinista de payments.charge (un ledger append-only, un store de idempotencia opcional, y un cliente que reintenta cuando la respuesta se pierde), comparaste dos mundos con la misma semilla. Un comprador: sin idempotencia paga $1000 por una compra de $500; con idempotency_key paga $500. La flota de mil órdenes: sin idempotencia cobra $305,118 cuando debía $256,979 —$48,139 de sobrecobro (18.7%), 146 órdenes cobradas de más—; con idempotency_key cobra exactamente $256,979, cero de más.
Aprendiste tres cosas operativas: que los intentos son idénticos en ambos mundos (1.184) porque la política de reintentos es del cliente, y lo que cambia es qué hace el servidor con ellos (1.184 cargos vs 1.000); que el total cobrado contra la intención es la métrica que revela el doble cargo, invisible en la tasa de éxito; y que el cliente no puede arreglarlo solo porque no tiene la información de si el cobro ocurrió —la corrección va en el servidor—.
Antes de avanzar deberías poder: leer la tabla de la flota y explicar cada número; justificar por qué los 1.184 intentos son iguales con y sin idempotencia; y explicar por qué reconciliar después es peor que deduplicar antes.
Lo que sigue es la primera mitad de la cura. La lección 3 introduce la idempotency_key: el identificador único que el cliente genera una vez por operación y reenvía idéntico en cada reintento. Verás quién la genera y por qué, y medirás el error clásico que la arruina —generar una key nueva en cada intento, que vuelve a cobrar $1500 aunque el servidor sea perfectamente idempotente—.
Recursos
- Brandur Leach, "Designing robust and predictable APIs with idempotency", Stripe Blog — stripe.com/blog/idempotency. Describe con el mismo escenario de esta lección —la respuesta perdida, el cliente que reintenta— por qué una API de cobros necesita idempotencia y cómo se ve el doble cargo sin ella. Gratis y en inglés.
- Malcolm Featonby, "Making retries safe with idempotent APIs", Amazon Builders' Library — aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs. El puente entre el reintento (módulo 3) y la idempotencia: por qué todo reintento de una operación mutante exige que esa operación sea segura de repetir. Gratis y en inglés.
- Marc Brooker, "Timeouts, retries, and backoff with jitter", Amazon Builders' Library — aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter. El artículo del módulo 3; su sección sobre reintentos explica por qué un timeout cuenta como respuesta perdida, generando el escenario que aquí se mide. Gratis y en inglés.
- Michael T. Nygard, Release It!, 2ª ed. (Pragmatic Bookshelf, 2018) — el tratamiento de la recuperación tras fallas y por qué las operaciones que cambian estado son las peligrosas de reintentar; el contexto conceptual del ledger que aquí simulamos. En inglés.