lección 4
Patrones: fan-out, dead letter queues y reintentos
Los patrones enterprise de mensajería: distribuir a múltiples consumidores, gestionar fallos con DLQ y diseñar reintentos inteligentes.
⏱ 55 min
### Los patrones que separan al junior del senior
Ya sabes publicar y consumir eventos. Enhorabuena, estás al nivel de un tutorial de 10 minutos. Ahora viene lo que realmente importa en producción: ¿qué pasa cuando las cosas FALLAN? Porque van a fallar. El servicio de emails se cae a las 3AM. La base de datos tiene un deadlock. La API externa devuelve un 503. En un sistema distribuido, el fallo no es la excepción — es la norma. Los patrones de esta lección son la diferencia entre un sistema que se recupera solo y uno que necesita que alguien se levante a las 3AM.
### Patrón 1: Fan-out (uno a muchos)
Ya lo viste en la lección anterior: un evento llega a EventBridge y se distribuye a MÚLTIPLES targets simultáneamente. Pero hay sutilezas. ¿Qué pasa si uno de los targets falla? Los demás NO se ven afectados — cada target es independiente. Si la cola de emails falla pero la de inventario funciona, el inventario se actualiza normalmente. Este aislamiento de fallos es una de las grandes ventajas del patrón fan-out.
La analogía: es como una emisora de radio. La emisora transmite una vez, y miles de radios la captan independientemente. Si tu radio se rompe, las demás siguen funcionando. La emisora ni se entera.
### Patrón 2: Dead Letter Queue (DLQ)
La Dead Letter Queue es la "morgue" de los mensajes. Cuando un consumidor falla al procesar un mensaje N veces (configurable), en vez de reintentar infinitamente, el mensaje se MUEVE a una cola especial (la DLQ) donde queda almacenado para investigación. Es como la bandeja de "devuelto al remitente" en correos: no pudimos entregar esta carta, aquí la tienes para que investigues por qué.
Sin DLQ, un mensaje "venenoso" (que siempre falla) consume todos los reintentos de la cola antes de desaparecer, gastando capacidad del consumidor y retrasando los mensajes buenos que esperan detrás. Con DLQ, los mensajes problemáticos se apartan tras N intentos y los buenos siguen fluyendo sin esperas innecesarias. Es OBLIGATORIO en producción. Si no tienes DLQ, no estás listo para producción.
1import boto32import json34sqs = boto3.client("sqs", endpoint_url="http://localhost:4566",5 region_name="eu-west-1", aws_access_key_id="test",6 aws_secret_access_key="test")78# 1. Crear la DLQ9sqs.create_queue(QueueName="pagos-dlq")1011# 2. Crear la cola principal con RedrivePolicy12sqs.create_queue(13 QueueName="pagos",14 Attributes={15 "RedrivePolicy": json.dumps({16 "deadLetterTargetArn": "arn:aws:sqs:eu-west-1:000000000000:pagos-dlq",17 "maxReceiveCount": "3" # Tras 3 fallos → DLQ18 }),19 "VisibilityTimeout": "30"20 }21)22print("Cola 'pagos' con DLQ configurada (max 3 reintentos)")
Configurar una cola con Dead Letter Queue: tras 3 fallos el mensaje va a la morgue
### Patrón 3: Reintentos con backoff exponencial
Cuando un procesamiento falla, la reacción instintiva es reintentar inmediatamente. Pero si el fallo es porque el servicio externo está sobrecargado, reintentar inmediatamente lo sobrecarga MÁS. El patrón correcto es backoff exponencial: espera 1s, luego 2s, luego 4s, luego 8s. Cada reintento espera el DOBLE que el anterior. Así le das tiempo al servicio de recuperarse.
Añade "jitter" (aleatoriedad) al backoff para evitar que cientos de consumidores reintentan TODOS al mismo segundo exacto (thundering herd problem). En vez de esperar exactamente 4s, espera entre 3s y 5s (±25% aleatorio). El jitter pequeño rompe la sincronización sin distorsionar demasiado la espera.
1import time2import random34def procesar_con_backoff(mensaje: dict, max_retries: int = 5) -> bool:5 """Procesa un mensaje con backoff exponencial + jitter."""6 for intento in range(max_retries):7 try:8 # Simular procesamiento que puede fallar9 resultado = procesar_mensaje(mensaje)10 print(f" Intento {intento+1}: OK")11 return True12 except Exception as e:13 if intento == max_retries - 1:14 print(f" Intento {intento+1}: FALLO DEFINITIVO - {e}")15 return False # Va a la DLQ1617 # Backoff exponencial con jitter18 base_wait = 2 ** intento # 1, 2, 4, 8, 1619 jitter = random.uniform(0, base_wait * 0.5)20 wait = base_wait + jitter21 print(f" Intento {intento+1}: fallo, reintentando en {wait:.1f}s...")22 time.sleep(wait)2324def procesar_mensaje(msg):25 """Simula un procesamiento que falla el 60% de las veces."""26 if random.random() < 0.6:27 raise Exception("Servicio externo no disponible")28 return "procesado"
Backoff exponencial con jitter: esperas crecientes + aleatoriedad
### Patrón 4: Circuit Breaker
Si un servicio externo lleva fallando 50 veces seguidas, ¿tiene sentido seguir intentando? No. El circuit breaker es como un fusible eléctrico: cuando detecta demasiados fallos consecutivos, "abre el circuito" y deja de intentar durante un tiempo. Después de un periodo de enfriamiento, permite UNA petición de prueba. Si funciona, cierra el circuito y vuelve a la normalidad. Si falla, lo abre otra vez.
1import time23class CircuitBreaker:4 def __init__(self, max_failures: int = 5, reset_timeout: int = 60):5 self.max_failures = max_failures6 self.reset_timeout = reset_timeout7 self.failures = 08 self.state = "closed" # closed, open, half-open9 self.last_failure_time = 01011 def call(self, func, *args):12 if self.state == "open":13 if time.time() - self.last_failure_time > self.reset_timeout:14 self.state = "half-open"15 print(" Circuit: half-open (probando...)")16 else:17 raise Exception("Circuit OPEN: rechazando llamada")1819 try:20 result = func(*args)21 if self.state == "half-open":22 self.state = "closed"23 self.failures = 024 print(" Circuit: cerrado (recuperado!)")25 return result26 except Exception as e:27 self.failures += 128 self.last_failure_time = time.time()29 if self.failures >= self.max_failures:30 self.state = "open"31 print(f" Circuit: ABIERTO tras {self.failures} fallos")32 raise e3334# Uso: el circuit breaker protege llamadas a servicios externos35cb = CircuitBreaker(max_failures=3, reset_timeout=30)
Circuit breaker: protege contra cascadas de fallos en servicios externos
### Patrón 5: Mensajes envenenados (poison messages)
Un mensaje "envenenado" es aquel que SIEMPRE va a fallar sin importar cuántas veces lo reintentes. Ejemplo: un JSON malformado, un pedido con un producto que no existe, un cliente borrado. La DLQ captura estos mensajes, pero deberías DETECTARLOS rápido para no gastar reintentos inútiles. Valida el mensaje ANTES de procesarlo: si no cumple el contrato, envíalo directamente a la DLQ sin reintentar.
1def consumir_con_validacion(mensaje: dict) -> str:2 """Detecta mensajes envenenados antes de intentar procesarlos."""3 # Validación rápida del contrato4 required_fields = ["pedido_id", "cliente_id", "total"]5 detail = mensaje.get("detail", {})67 missing = [f for f in required_fields if f not in detail]8 if missing:9 # Mensaje envenenado: no reintentar, directo a DLQ10 print(f" POISON MSG: faltan campos {missing} - enviando a DLQ")11 enviar_a_dlq(mensaje, razon=f"Campos faltantes: {missing}")12 return "poison_detected"1314 if not isinstance(detail.get("total"), (int, float)) or detail["total"] <= 0:15 print(f" POISON MSG: total inválido ({detail.get('total')})")16 enviar_a_dlq(mensaje, razon="Total inválido")17 return "poison_detected"1819 # Mensaje válido: procesar normalmente20 return procesar_pedido_real(detail)
Detectar mensajes envenenados ANTES de gastar reintentos
### Monitorización: la DLQ no sirve si nadie la mira
Una DLQ sin alertas es como un detector de humo sin batería: existe pero no te va a salvar. SIEMPRE que configures una DLQ, configura una alerta que te avise cuando llega un mensaje. En AWS puedes usar CloudWatch Alarms; en local, un consumidor que lea la DLQ periódicamente y envíe una notificación.
- 01.Configura una métrica: "número de mensajes en la DLQ"
- 02.Alerta si > 0 mensajes en la DLQ (algo falló)
- 03.Alerta si > 100 mensajes (algo falla SISTEMÁTICAMENTE)
- 04.Revisa la DLQ diariamente aunque no haya alertas
- 05.Implementa un script de "replay": reprocesar mensajes de la DLQ cuando el bug se corrige
Consejo de senior: implementa un script de "replay" desde el primer día. Cuando corrijas el bug que causó los mensajes en la DLQ, necesitas una forma de REPROCESARLOS. Si no tienes ese script, terminas copiando y pegando mensajes a mano a las 3AM. Pregúntame cómo lo sé.
Lo que le diría a mi yo de hace 5 años: pon el maxReceiveCount en 3-5, no en 1 ni en 100. Con 1, cualquier glitch transitorio te llena la DLQ. Con 100, un mensaje envenenado bloquea a tu consumidor durante horas antes de rendirse.
La DLQ tiene retención limitada (por defecto 4 días en SQS). Si no la revisas en ese tiempo, los mensajes SE PIERDEN PARA SIEMPRE. Configura la retención al máximo (14 días) y monta alertas.
### Resumen de patrones
- Fan-out: un evento dispara múltiples consumidores independientes
- DLQ: los mensajes que fallan N veces se apartan para investigación
- Backoff exponencial: reintentos con esperas crecientes + jitter
- Circuit breaker: deja de llamar a servicios que están caídos
- Poison message detection: valida antes de procesar para no gastar reintentos
- Replay: reprocesar mensajes de la DLQ cuando el bug se corrige
## ejercicios
Implementar backoff exponencial con jitter
Escribe una función retry_with_backoff que reciba una función y la ejecute con reintentos. Usa backoff exponencial (base 2) con jitter aleatorio de ±25%. Retorna el resultado o lanza excepción si agota los reintentos.
💡 Resultado esperado
Intento 1 falló. Esperando 1.07s... Intento 2 falló. Esperando 1.53s... Intento 3 falló. Esperando 3.55s... Resultado: exito! (tras 4 intentos)
Implementar un circuit breaker funcional
Implementa un circuit breaker con estados closed/open/half-open. En estado open rechaza llamadas. Tras un timeout pasa a half-open y permite un intento de prueba.
💡 Resultado esperado
Call 1: ERROR - servicio caído [state=closed] Call 2: ERROR - servicio caído [state=closed] Call 3: ERROR - servicio caído [state=open] Call 4: ERROR - Circuit OPEN: llamada rechazada [state=open]
Script de replay: reprocesar mensajes de la DLQ
Escribe un script que lea todos los mensajes de la DLQ "pagos-dlq" y los reenvíe a la cola principal "pagos" para reprocesamiento. Implementa confirmación antes de borrar de la DLQ. Qué deberías ver: por cada mensaje reprocesado, una línea "Replayed: ..." con los primeros 50 caracteres del body; al final, "Replay completado: N mensajes reprocesados". Si la DLQ está vacía, verás directamente "Replay completado: 0 mensajes reprocesados".
Detector de mensajes envenenados
Implementa un consumidor que valida mensajes ANTES de procesarlos. Si un mensaje no cumple el contrato (falta pedido_id, total negativo o no numérico, detail ausente o no es diccionario), clasifícalo como envenenado y repórtalo sin gastar reintentos.
💡 Resultado esperado
OK: ORD-1 POISON: Total inválido: -10 POISON: Falta 'pedido_id' POISON: 'detail' no es un diccionario
Regístrate para guardar tu progreso.
## comentarios
Reporta erratas, ayuda a otros o comparte tu opinión. Sé constructivo.
Inicia sesión para comentar y responder.
cargando comentarios...