Módulo 7: Flujos Avanzados
Ciclos y Loops: Retry Patterns
Descripción de la cápsula
Tu agente de investigación llama a una API de búsqueda web. El 90% de las veces, funciona. El 10% restante: timeout, 429 Too Many Requests, 503 Service Unavailable, o simplemente una conexión que se corta a mitad de la respuesta. Sin retry, tu agente crashea y el usuario ve un error. La investigación completa se pierde por un fallo transitorio que habría funcionado 2 segundos después.
Retry es el patrón más fundamental de sistemas distribuidos. Pero retry mal implementado es peor que no hacer retry. Si tu API devuelve 429 (rate limit) y tu código reintenta inmediatamente, le estás mandando más requests a una API que ya te dijo "para." Si 100 clientes hacen lo mismo simultáneamente, la API colapsa. Eso tiene nombre: DDoS involuntario.
En esta cápsula vas a implementar retry correctamente desde el principio: con backoff exponencial (esperas cada vez más entre intentos) y jitter aleatorio (no todos los clientes reintentan al mismo tiempo). Lo implementarás de dos formas: como ciclo en un StateGraph (conditional edge que loop back) y como while loop en la Functional API. Ambos approaches son válidos — la elección depende de si tu workflow es un grafo o una función.
El problema: APIs que fallan
Tu Research Agent v1 llama a una API de búsqueda web. Cuando la API devuelve 429 Too Many Requests o timeout, tu agente muere. Todo el pipeline — descomposición, búsqueda, síntesis — se pierde por un fallo transitorio que habría funcionado 2 segundos después.
No todos los errores merecen retry. La distinción crítica:
| Tipo | Ejemplos | ¿Retry? |
|---|---|---|
| Transitorio | 429 Too Many Requests, 503, Timeout | ✅ Espera y reintenta |
| Permanente | 401 Unauthorized, 404, 400 Bad Request | ❌ No va a funcionar |
Retry solo tiene sentido para errores transitorios — donde el mismo request, enviado segundos después, tiene probabilidad razonable de funcionar.
Retry ingenuo: por qué no funciona
El primer instinto es: si falla, reintenta inmediatamente. Pero mira la timeline:
Timeline con retry inmediato (3 intentos):
t=0.00s → Request 1 — 429 Too Many Requests
t=0.01s → Request 2 — 429 Too Many Requests
t=0.02s → Request 3 — 429 Too Many Requests
t=0.03s → Exception: "Falló después de 3 intentos"
3 requests en 30 milisegundos a una API que te dijo "estoy saturada." Si 100 usuarios hacen lo mismo, la API recibe 300 requests en 30ms. Retry sin backoff amplifica el problema en vez de resolverlo.
Backoff exponencial con jitter: la solución correcta
La fórmula
import random
delay = min(base_delay * (2 ** attempt) + random.uniform(0, jitter), max_delay)
| Componente | Qué hace | Valor típico |
|---|---|---|
base_delay | Tiempo base de espera | 1.0 segundo |
2 ** attempt | Duplica la espera en cada intento | 1, 2, 4, 8, 16... |
jitter | Variación aleatoria para desincronizar clientes | 0.5 segundos |
max_delay | Tope máximo de espera | 30 segundos |
Cómo se ve en práctica
Timeline con backoff exponencial + jitter:
t=0.00s → Request 1 — 429 Too Many Requests
t=1.23s → Request 2 — 429 Too Many Requests (esperó ~1.2s)
t=3.67s → Request 3 — 200 OK ✅ (esperó ~2.4s)
Total: 3.67 segundos, 3 intentos, exitoso
Comparado con retry inmediato:
- Le diste tiempo a la API de recuperarse
- Cada intento espera más que el anterior
- El jitter aleatorio evita que 100 clientes reintentenexactamente al mismo segundo
Esa es la teoría. Ahora veamos cómo implementar el mismo concepto dentro de LangGraph, donde el retry es parte de la topología del grafo o del control flow de una función.
Ciclos en grafos: un edge que vuelve atrás
En StateGraph, un ciclo es un conditional edge que rutea un nodo de vuelta a sí mismo o a un nodo anterior:
┌──────────── retry ──────────────┐
│ │
▼ │
START → search_node → should_retry ─── success → END
│
└── max_retries_exceeded → fallback_node → END
Implementación completa con StateGraph
import time
import random
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class RetryState(TypedDict):
query: str
result: str
retry_count: int
max_retries: int
last_error: str
status: str
def search_node(state: RetryState) -> dict:
if random.random() < 0.6:
error_msg = f"503 Service Unavailable (intento {state['retry_count'] + 1})"
print(f"[SEARCH] ❌ Falló: {error_msg}")
return {"last_error": error_msg, "status": "error"}
print(f"[SEARCH] ✅ Éxito en intento {state['retry_count'] + 1}")
return {"result": f"Resultados para: {state['query']}", "last_error": "", "status": "success"}
def wait_with_backoff(state: RetryState) -> dict:
attempt = state["retry_count"]
delay = min(1.0 * (2 ** attempt) + random.uniform(0, 0.5), 30.0)
print(f"[BACKOFF] Esperando {delay:.2f}s (intento {attempt + 1})")
time.sleep(delay)
return {"retry_count": attempt + 1}
def fallback_node(state: RetryState) -> dict:
return {"result": f"Resultado parcial para '{state['query']}' — fuente no disponible", "status": "fallback"}
def should_retry(state: RetryState) -> str:
if state["status"] == "success":
return "done"
if state["retry_count"] >= state["max_retries"]:
return "fallback"
return "retry"
builder = StateGraph(RetryState)
builder.add_node("search", search_node)
builder.add_node("wait_backoff", wait_with_backoff)
builder.add_node("fallback", fallback_node)
builder.add_edge(START, "search")
builder.add_conditional_edges("search", should_retry, {
"done": END, "retry": "wait_backoff", "fallback": "fallback"
})
builder.add_edge("wait_backoff", "search")
builder.add_edge("fallback", END)
graph = builder.compile()
result = graph.invoke({
"query": "prompt engineering best practices",
"result": "", "retry_count": 0, "max_retries": 3, "last_error": "", "status": ""
})
print(f"\nResultado: {result['result']}")
print(f"Status: {result['status']}, Intentos: {result['retry_count'] + 1}")
# Output esperado (varía por el random):
# [SEARCH] ❌ Falló: 503 Service Unavailable (intento 1)
# [BACKOFF] Esperando 1.23s (intento 1)
# [SEARCH] ✅ Éxito en intento 3
# Resultado: Resultados para: prompt engineering best practices
# Status: success, Intentos: 3
El ciclo es el edge wait_backoff → search — un loop explícito en la topología del grafo. El conditional edge should_retry decide en cada iteración: éxito → END, fallo con intentos restantes → backoff → search (loop), fallo sin intentos → fallback → END.
Retry en la Functional API: for loop con try/except
El mismo patrón sin topología de grafo — Python puro con @entrypoint y @task:
import time
import random
from langgraph.func import entrypoint, task
@task
def search_web(query: str) -> str:
if random.random() < 0.6:
raise ConnectionError(f"503 Service Unavailable buscando '{query}'")
return f"Resultados de búsqueda para: {query}"
@entrypoint()
def research_with_retry(query: str) -> dict:
max_retries, base_delay = 3, 1.0
for attempt in range(max_retries):
try:
result = search_web(query).result()
return {"query": query, "result": result, "status": "success"}
except Exception as e:
print(f"[RETRY] Intento {attempt + 1} falló: {e}")
if attempt < max_retries - 1:
delay = min(base_delay * (2 ** attempt) + random.uniform(0, 0.5), 30.0)
time.sleep(delay)
return {"query": query, "result": f"Resultado parcial — fuente no disponible", "status": "fallback"}
result = research_with_retry.invoke("prompt engineering best practices")
print(f"Resultado: {result['result']}, Status: {result['status']}")
# Output esperado (varía):
# [RETRY] Intento 1 falló: 503 Service Unavailable buscando '...'
# Resultado: Resultados de búsqueda para: prompt engineering best practices, Status: success
Más compacto. Misma lógica. El for loop reemplaza al ciclo en el grafo, el try/except reemplaza al conditional edge, y el código después del loop reemplaza al fallback node.
Comparación: retry en StateGraph vs Functional API
| Aspecto | StateGraph | Functional API |
|---|---|---|
| Ciclo | Conditional edge que loop back | for/while con try/except |
| Estado | retry_count en TypedDict | Variable local attempt |
| Backoff | Nodo dedicado | time.sleep() inline |
| Checkpointing | Cada nodo (granular) | Solo @task |
| Líneas | ~50 | ~30 |
Regla de decisión: si el usuario necesita ver "reintentando búsqueda..." en el stream → StateGraph (el ciclo es visible y checkpointeable). Si el retry es un detalle interno → Functional API (más compacto). En la mayoría de los casos, el retry es interno.
Filtrar errores: qué merece retry y qué no
Un retry que no distingue entre errores transitorios y permanentes desperdicia tiempo. Si la API devuelve 401 Unauthorized, reintentar 3 veces con 7 segundos de backoff no va a cambiar nada — tu API key sigue siendo inválida.
import httpx
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
RETRYABLE_EXCEPTIONS = (httpx.TimeoutException, httpx.ConnectError, ConnectionError, TimeoutError)
def is_retryable(error: Exception) -> bool:
"""Determina si un error merece retry."""
if isinstance(error, httpx.HTTPStatusError):
return error.response.status_code in RETRYABLE_STATUS_CODES
return isinstance(error, RETRYABLE_EXCEPTIONS)
Integrado en el retry, el patrón es: captura la excepción, verifica si es retryable, y solo entonces aplica backoff. Si no es retryable, propaga el error inmediatamente con raise:
import time
import random
import httpx
def search_with_smart_retry(query: str, max_retries: int = 3, base_delay: float = 1.0) -> dict:
"""Retry inteligente — solo reintenta errores transitorios."""
for attempt in range(max_retries):
try:
response = httpx.get("https://api.example.com/search", params={"q": query}, timeout=10.0)
response.raise_for_status()
return response.json()
except Exception as e:
if not is_retryable(e):
print(f"[SEARCH] Error permanente (no retryable): {e}")
raise
if attempt < max_retries - 1:
delay = min(base_delay * (2 ** attempt) + random.uniform(0, 0.5), 30.0)
print(f"[RETRY] Intento {attempt + 1}: {e}. Esperando {delay:.2f}s")
time.sleep(delay)
raise Exception(f"Búsqueda falló después de {max_retries} intentos")
429 → reintenta con backoff. 401 → propaga inmediatamente. Esa distinción evita desperdiciar tiempo en errores irrecuperables.
Break conditions: cuándo dejar de reintentar
max_retries no es la única condición de parada. En producción, necesitas un timeout budget — un tiempo total máximo para toda la operación de retry. No importa si tienes 5 intentos restantes; si ya pasaron 30 segundos, el usuario no va a esperar más.
import time
import random
def search_with_timeout_budget(query: str, max_retries: int = 5, timeout_budget: float = 15.0) -> dict:
"""Retry con presupuesto de tiempo total."""
start_time = time.time()
for attempt in range(max_retries):
elapsed = time.time() - start_time
remaining = timeout_budget - elapsed
if remaining <= 0:
return {"status": "timeout", "query": query, "attempts": attempt}
try:
if random.random() < 0.5:
raise ConnectionError("Simulated failure")
return {"status": "success", "result": f"Results for: {query}", "attempts": attempt + 1}
except Exception as e:
if attempt < max_retries - 1:
delay = min(1.0 * (2 ** attempt) + random.uniform(0, 0.5), remaining)
if delay <= 0:
break
print(f"[RETRY] Intento {attempt + 1}: {e}. Esperando {delay:.2f}s ({remaining:.1f}s restantes)")
time.sleep(delay)
return {"status": "failed", "query": query, "attempts": max_retries}
result = search_with_timeout_budget("AI safety", timeout_budget=10.0)
print(f"Status: {result['status']}, Intentos: {result['attempts']}")
# Output esperado (varía):
# [RETRY] Intento 1: Simulated failure. Esperando 1.23s (9.8s restantes)
# Status: success, Intentos: 2
El detalle clave: delay = min(..., remaining). Si solo quedan 2 segundos de budget y el backoff sugiere 4 segundos, espera solo 2. Dos condiciones de parada, la que se alcance primero gana: max_retries O timeout_budget.
recursion_limit: el safety net de LangGraph
LangGraph tiene un mecanismo de seguridad para ciclos: recursion_limit. Si un grafo ejecuta más de N supersteps (transiciones entre nodos), lanza GraphRecursionError. El default es 25 supersteps.
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class CounterState(TypedDict):
counter: int
def increment(state: CounterState) -> dict:
print(f"Counter: {state['counter']}")
return {"counter": state["counter"] + 1}
def always_loop(state: CounterState) -> str:
return "loop"
builder = StateGraph(CounterState)
builder.add_node("increment", increment)
builder.add_edge(START, "increment")
builder.add_conditional_edges("increment", always_loop, {"loop": "increment"})
graph = builder.compile()
try:
result = graph.invoke({"counter": 0}, {"recursion_limit": 5})
except Exception as e:
print(f"\nError: {type(e).__name__}: {e}")
# Output esperado:
# Counter: 0
# Counter: 1
# ...
# Counter: 4
# Error: GraphRecursionError: Recursion limit of 5 reached...
Si tu retry loop tiene un bug y should_retry nunca retorna "done", el recursion_limit evita un loop infinito. Siempre configura ambos: el límite de negocio (max_retries) y el límite de seguridad (recursion_limit). Para un ciclo de retry con 2 nodos por iteración (search + backoff), configura: recursion_limit = max_retries * 2 + 5.
Retry con context: llevar información entre intentos
A veces la API devuelve un header Retry-After que te dice cuánto esperar. Agrega suggested_delay al estado para que el nodo de backoff lo use:
def adaptive_backoff(state: dict) -> dict:
attempt = state["retry_count"]
if state.get("suggested_delay", 0) > 0:
delay = state["suggested_delay"] + random.uniform(0, 0.5)
else:
delay = min(1.0 * (2 ** attempt) + random.uniform(0, 0.5), 30.0)
time.sleep(delay)
return {"retry_count": attempt + 1}
El estado del grafo lleva información entre iteraciones del ciclo. Datos del intento N influyen en la estrategia del intento N+1.
Troubleshooting
Problema 1: "GraphRecursionError" inesperado
Síntoma: GraphRecursionError antes de agotar los reintentos.
Causa: recursion_limit default = 25. Cada reintento con 2 nodos (search + backoff) consume 2 supersteps.
Solución: graph.invoke(state, {"recursion_limit": max_retries * 2 + 5}).
Problema 2: Todos los clientes reintentan al mismo tiempo
Síntoma: Después de un pico, todos los retries causan otro pico.
Causa: Backoff sin jitter — todos calculan el mismo delay.
Solución: Siempre incluye random.uniform(0, jitter) sumado al delay.
Problema 3: Retry reintenta errores permanentes
Síntoma: 7 segundos de retry contra un 401 Unauthorized.
Causa: No filtras el tipo de error.
Solución: Implementa is_retryable(error) — solo reintenta 429, 500, 502, 503, 504.
Problema 4: El delay crece indefinidamente
Síntoma: En el intento 10, el delay es 1024 segundos.
Causa: Backoff sin max_delay.
Solución: Siempre usa min(delay, max_delay) con max_delay=30.
Problema 5: Tests inconsistentes
Síntoma: Tests pasan a veces por depender de random.random().
Solución: Usa random.seed(42) al inicio del test para determinismo.
Ejercicios
Ejercicio 1: Backoff exponencial básico (Fácil)
Escribe una función calculate_delays que reciba max_retries, base_delay, y jitter=0 (sin jitter para verificar el patrón) y retorne una lista con el delay de cada intento. Verifica que los delays se duplican: [1.0, 2.0, 4.0, 8.0] para base_delay=1.0 y max_retries=4.
Ver solución
def calculate_delays(
max_retries: int,
base_delay: float = 1.0,
jitter: float = 0.0,
max_delay: float = 60.0
) -> list[float]:
"""Calcula la secuencia de delays para retry con backoff exponencial."""
import random
delays = []
for attempt in range(max_retries):
delay = min(
base_delay * (2 ** attempt) + random.uniform(0, jitter),
max_delay
)
delays.append(round(delay, 2))
return delays
delays_no_jitter = calculate_delays(max_retries=4, base_delay=1.0, jitter=0.0)
print(f"Sin jitter: {delays_no_jitter}")
# Output esperado: Sin jitter: [1.0, 2.0, 4.0, 8.0]
delays_with_jitter = calculate_delays(max_retries=4, base_delay=1.0, jitter=0.5)
print(f"Con jitter: {delays_with_jitter}")
# Output esperado: Con jitter: [1.23, 2.41, 4.15, 8.33] (varía por random)
delays_with_cap = calculate_delays(max_retries=6, base_delay=1.0, jitter=0.0, max_delay=10.0)
print(f"Con cap: {delays_with_cap}")
# Output esperado: Con cap: [1.0, 2.0, 4.0, 8.0, 10.0, 10.0]
assert delays_no_jitter == [1.0, 2.0, 4.0, 8.0], "Backoff debe duplicarse"
assert all(d <= 10.0 for d in delays_with_cap), "max_delay debe respetarse"
print("\n✅ Todos los assertions pasaron")
Explicación: La fórmula base_delay * (2 ** attempt) produce la secuencia geométrica. Sin jitter, es determinística: 1, 2, 4, 8, 16... El min(..., max_delay) asegura que el delay nunca exceda el tope.
Ejercicio 2: Retry con la Functional API (Fácil)
Crea un @entrypoint con un @task que simula una función que falla las primeras 2 veces y funciona en la tercera. Implementa retry con backoff dentro del entrypoint. Verifica que retorna exitosamente al tercer intento.
Ver solución
import time
import random
from langgraph.func import entrypoint, task
call_count = 0
@task
def flaky_api_call(query: str) -> str:
"""Simula una API que falla las primeras 2 veces."""
global call_count
call_count += 1
if call_count <= 2:
raise ConnectionError(f"Fallo simulado (intento {call_count})")
return f"Respuesta exitosa para: {query}"
@entrypoint()
def agent_with_retry(query: str) -> dict:
max_retries = 4
base_delay = 0.1 # Delays cortos para el ejercicio
for attempt in range(max_retries):
try:
result = flaky_api_call(query).result()
return {"result": result, "attempts": attempt + 1, "status": "success"}
except Exception as e:
print(f"[RETRY] Intento {attempt + 1}: {e}")
if attempt < max_retries - 1:
delay = base_delay * (2 ** attempt) + random.uniform(0, 0.05)
time.sleep(delay)
return {"result": "", "attempts": max_retries, "status": "failed"}
call_count = 0
result = agent_with_retry.invoke("test query")
print(f"\nResultado: {result['result']}")
print(f"Intentos: {result['attempts']}")
print(f"Status: {result['status']}")
# Output esperado:
# [RETRY] Intento 1: Fallo simulado (intento 1)
# [RETRY] Intento 2: Fallo simulado (intento 2)
#
# Resultado: Respuesta exitosa para: test query
# Intentos: 3
# Status: success
assert result["status"] == "success"
assert result["attempts"] == 3
print("✅ Retry funcionó correctamente")
Explicación: El @task falla 2 veces y tiene éxito en la tercera. El for loop dentro del @entrypoint implementa retry con backoff. El try/except captura el error del .result() y decide si reintentar.
Ejercicio 3: Retry con StateGraph y error log acumulado (Medio)
Implementa un StateGraph con un ciclo de retry donde el estado tenga errors: Annotated[list[str], operator.add] como reducer para acumular errores. Incluye nodos process, backoff, fallback, y un conditional edge should_retry. Al final, imprime la lista de errores para ver el historial completo de fallos.
Ver solución
import time
import random
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
class ProcessState(TypedDict):
input_data: str
result: str
retry_count: int
max_retries: int
errors: Annotated[list[str], operator.add]
status: str
def process_node(state: ProcessState) -> dict:
if random.random() < 0.7:
return {"errors": [f"Intento {state['retry_count'] + 1}: failed"], "status": "error"}
return {"result": f"Procesado: {state['input_data']}", "status": "success"}
def backoff_node(state: ProcessState) -> dict:
delay = min(0.1 * (2 ** state["retry_count"]) + random.uniform(0, 0.05), 5.0)
time.sleep(delay)
return {"retry_count": state["retry_count"] + 1}
def fallback_node(state: ProcessState) -> dict:
return {"result": f"Fallback para: {state['input_data']}", "status": "fallback"}
def should_retry(state: ProcessState) -> str:
if state["status"] == "success":
return "done"
return "fallback" if state["retry_count"] >= state["max_retries"] else "retry"
builder = StateGraph(ProcessState)
builder.add_node("process", process_node)
builder.add_node("backoff", backoff_node)
builder.add_node("fallback", fallback_node)
builder.add_edge(START, "process")
builder.add_conditional_edges("process", should_retry, {"done": END, "retry": "backoff", "fallback": "fallback"})
builder.add_edge("backoff", "process")
builder.add_edge("fallback", END)
graph = builder.compile()
random.seed(42)
result = graph.invoke({"input_data": "datos de prueba", "result": "", "retry_count": 0, "max_retries": 3, "errors": [], "status": ""})
print(f"Status: {result['status']}, Errores: {result['errors']}")
# Output esperado: Status: success/fallback, Errores: ['Intento 1: failed', ...]
Explicación: Annotated[list[str], operator.add] acumula errores de todos los intentos. Al final, result['errors'] tiene el historial completo.
Ejercicio 4: Filtro de errores retryable vs permanente (Medio)
Crea una función smart_retry que reciba una función y dos tuplas: retryable_exceptions y permanent_exceptions. Si la función lanza una excepción retryable, reintenta con backoff. Si lanza una permanente, la propaga inmediatamente. Testea con ValueError (permanente) y ConnectionError (retryable).
Ver solución
import time
import random
def smart_retry(func, max_retries=3, base_delay=0.1,
retryable=(ConnectionError, TimeoutError),
permanent=(ValueError, TypeError)):
last_exc = None
for attempt in range(max_retries):
try:
return func()
except permanent as e:
print(f"[PERMANENT] {type(e).__name__}: {e}")
raise
except retryable as e:
last_exc = e
print(f"[RETRY] {attempt + 1}/{max_retries}: {type(e).__name__}: {e}")
if attempt < max_retries - 1:
time.sleep(min(base_delay * (2 ** attempt) + random.uniform(0, 0.05), 10.0))
raise last_exc
# Test 1: retryable — funciona al tercer intento
call_count = 0
def flaky():
global call_count
call_count += 1
if call_count <= 2:
raise ConnectionError(f"Fallo {call_count}")
return "OK"
call_count = 0
print(smart_retry(flaky, max_retries=4))
# Output: [RETRY] 1/4: ... → [RETRY] 2/4: ... → OK
# Test 2: permanente — no reintenta
try:
smart_retry(lambda: (_ for _ in ()).throw(ValueError("bad input")), max_retries=4)
except ValueError as e:
print(f"Capturado: {e}")
# Output: [PERMANENT] ValueError: bad input → Capturado: bad input
Explicación: Los except permanentes se capturan primero y se propagan con raise. Los retryables entran al ciclo de backoff. El orden de los except blocks es lo que determina el comportamiento.
Ejercicio 5: Retry con timeout budget en @entrypoint (Medio-Avanzado)
Crea un @entrypoint que busque con retry y dos condiciones de parada: max_retries=10 y timeout_budget=3.0. Usa un @task que siempre falle para verificar que el budget se agota en ~3 segundos, no en 10 intentos. Loggea el tiempo transcurrido en cada intento.
Ver solución
import time
import random
from langgraph.func import entrypoint, task
@task
def always_fail(query: str) -> str:
raise ConnectionError(f"Service unavailable for: {query}")
@entrypoint()
def search_with_budget(inputs: dict) -> dict:
query, max_retries = inputs["query"], inputs.get("max_retries", 10)
timeout_budget, base_delay = inputs.get("timeout_budget", 10.0), inputs.get("base_delay", 1.0)
start_time = time.time()
attempts = 0
for attempt in range(max_retries):
remaining = timeout_budget - (time.time() - start_time)
if remaining <= 0:
break
attempts += 1
try:
return {"status": "success", "result": always_fail(query).result(), "attempts": attempts}
except Exception as e:
print(f"[RETRY] Intento {attempts} a t={time.time() - start_time:.2f}s: {e}")
if attempt < max_retries - 1:
delay = min(base_delay * (2 ** attempt) + random.uniform(0, 0.3), remaining)
if delay > 0:
time.sleep(delay)
elapsed = round(time.time() - start_time, 2)
return {"status": "budget_exhausted", "attempts": attempts, "elapsed": elapsed}
result = search_with_budget.invoke({"query": "test", "max_retries": 10, "timeout_budget": 3.0, "base_delay": 1.0})
print(f"\nStatus: {result['status']}, Intentos: {result['attempts']}, Tiempo: {result['elapsed']}s")
assert result["elapsed"] <= 4.0, f"Budget de 3s excedido: {result['elapsed']}s"
print(f"✅ Budget respetado: {result['elapsed']}s <= 4.0s")
# Output esperado:
# [RETRY] Intento 1 a t=0.00s: Service unavailable for: test
# [RETRY] Intento 2 a t=1.12s: Service unavailable for: test
# Status: budget_exhausted, Intentos: 3, Tiempo: ~3.0s
# ✅ Budget respetado
Explicación: Con max_retries=10 pero timeout_budget=3.0, el budget se agota después de ~3 intentos (delays: 1s, 2s). El budget, no el max_retries, es lo que detiene el loop.
Ejercicio 6: Retry encapsulado como building block (Avanzado)
Crea un @task llamado search_source_with_retry que encapsule toda la lógica de retry internamente. Recibe {"source": str, "query": str}, simula búsqueda con fallos, implementa retry con backoff + jitter, y retorna {"status", "source", "results", "attempts"}. Luego usa ese task desde un @entrypoint que busque en 3 fuentes. Este building block se reutilizará en la Cápsula 03 para branching paralelo.
Ver solución
import time
import random
from langgraph.func import entrypoint, task
FAILURE_RATES = {"web": 0.3, "papers": 0.5, "news": 0.2}
def simulate_search(source: str, query: str) -> list[dict]:
failure_rate = FAILURE_RATES.get(source, 0.3)
if random.random() < failure_rate:
raise ConnectionError(f"{source}: 503 Service Unavailable")
return [{"title": f"[{source}] Resultado para '{query}'", "relevance": 0.9}]
@task
def search_source_with_retry(inputs: dict) -> dict:
source, query = inputs["source"], inputs["query"]
max_retries, base_delay = 3, 0.5
errors = []
for attempt in range(max_retries):
try:
results = simulate_search(source, query)
return {"status": "success", "source": source, "results": results, "attempts": attempt + 1}
except ConnectionError as e:
errors.append(str(e))
if attempt < max_retries - 1:
delay = min(base_delay * (2 ** attempt) + random.uniform(0, 0.3), 10.0)
time.sleep(delay)
return {"status": "exhausted", "source": source, "results": [], "attempts": max_retries, "errors": errors}
@entrypoint()
def multi_source_search(query: str) -> dict:
sources = ["web", "papers", "news"]
futures = [search_source_with_retry({"source": s, "query": query}) for s in sources]
results = [f.result() for f in futures]
successful = [r for r in results if r["status"] == "success"]
return {"query": query, "successful": len(successful), "total": len(sources), "results": results}
random.seed(123)
report = multi_source_search.invoke("retry patterns")
print(f"Fuentes exitosas: {report['successful']}/{report['total']}")
for r in report["results"]:
icon = "✅" if r["status"] == "success" else "⚠️"
print(f" {icon} {r['source']}: {r['status']} ({r['attempts']} intentos)")
# Output esperado (varía con seed):
# Fuentes exitosas: 3/3
# ✅ web: success (1 intentos)
# ✅ papers: success (2 intentos)
# ✅ news: success (1 intentos)
Explicación: search_source_with_retry encapsula retry, backoff, y jitter en un solo @task. El @entrypoint lo usa por fuente sin conocer la lógica de retry. En la Cápsula 03, este mismo task se ejecutará en paralelo con branching.
Resumen
En esta cápsula aprendiste:
- Retry sin backoff es un anti-patrón. Reintentar inmediatamente amplifica el problema — le mandas más requests a una API que ya te dijo "para." Siempre usa backoff exponencial con jitter
- La fórmula:
delay = min(base * 2^attempt + random.uniform(0, jitter), max_delay). Duplica la espera en cada intento, agrega variación aleatoria, y respeta un tope máximo - Distingue errores transitorios de permanentes. 429 y 503 merecen retry. 401 y 404 no. Reintentar errores permanentes desperdicia tiempo
- En StateGraph, el retry es un ciclo explícito: conditional edge que rutea de vuelta a un nodo. El estado lleva
retry_countylast_error. La topología es visible y streameable - En Functional API, el retry es un for/while loop con try/except y
time.sleep(). Más compacto, mismo efecto. El retry es un detalle de implementación oculto - Dos condiciones de parada:
max_retries(número de intentos) ytimeout_budget(tiempo total). La que se alcance primero, gana recursion_limites el safety net de LangGraph contra loops infinitos. Configúralo siempre por encima de tumax_retriesesperado- El retry con context lleva información entre intentos (como
Retry-Afterheaders) para adaptar la estrategia
Próxima cápsula: Branching y Merge: Ejecución Paralela — tu Research Agent busca en 3 fuentes secuencialmente (9 segundos). Vas a ejecutar las 3 búsquedas en paralelo (3 segundos) usando fan-out/fan-in y la Send API de LangGraph.
Recursos adicionales
- Exponential Backoff and Jitter (AWS Architecture Blog) — El artículo definitivo sobre backoff con jitter. Compara full jitter, equal jitter, y decorrelated jitter con simulaciones
- LangGraph — Concepts: Cycles — Documentación oficial sobre cómo LangGraph maneja ciclos y el
recursion_limit - LangGraph — How to control graph recursion limit — Guía práctica para configurar y manejar el límite de recursión
- Retry Pattern — Microsoft Azure Architecture — Documentación de Microsoft sobre el patrón de retry con guía de implementación
- Circuit Breaker Pattern — Martin Fowler — El siguiente nivel después de retry: cuando dejar de intentar por completo. Preview del Módulo 7 Cápsula 06
- httpx — Timeouts — Referencia de timeouts en httpx, la librería HTTP usada en los ejemplos
Módulo 7 — LangChain & LangGraph: From Chains to Agents
Siguiente cápsula: Branching y Merge: Ejecución Paralela — aprenderás fan-out/fan-in para ejecutar búsquedas en paralelo, la Send API de LangGraph, y cómo mergear resultados de múltiples fuentes con deduplicación.