Estrategias de Recuperación de Errores y Reintentos en Bucles de Agentes
Cuando un orquestador entrega trabajo a un subagente o una herramienta, esa llamada puede fallar por completo, colgarse indefinidamente o regresar con un resultado que parece correcto pero es erróneo o incompleto. Esta página cubre cómo detectar cada uno de esos modos de fallo dentro de un bucle de agente y responder con reintentos, mecanismos de respaldo y disyuntores, para que una llamada fallida no derribe toda la ejecución.
Un bucle de agente que delega a subagentes o herramientas tiene una nueva superficie de fallo más allá de "la llamada a la API generó un error": un subagente puede atascarse y nunca regresar, o regresar con un resultado que es técnicamente exitoso pero inútil.
Reintentar ciegamente no es una estrategia. Un reintento solo ayuda para fallos transitorios, empeora una dependencia rota al sobrecargarla y puede duplicar efectos secundarios si la llamada subyacente no fue idempotente.
Esta página trata la recuperación de errores a nivel de orquestación como tres decisiones en capas: reintentar la misma llamada un número limitado de veces con retroceso (backoff), recurrir a una ruta más barata o diferente cuando se agotan los reintentos, y dejar de llamar a una dependencia por completo una vez que ha fallado claramente más allá de un umbral, revisándola más tarde.
También cubre la detección de un subagente atascado, uno que nunca genera un error y nunca regresa, ya que ese modo de fallo necesita un tiempo de espera explícito en lugar de un manejador de excepciones.
import asyncioimport randomimport timefrom dataclasses import dataclass, fieldfrom claude_agent_sdk import query, AgentOptions, SubagentConfigclass CircuitOpenError(Exception): """Se genera cuando un disyuntor rechaza una llamada porque su dependencia no está saludable."""@dataclassclass CircuitBreaker: """Disyuntor por dependencia con una sonda semierta basada en tiempo de espera (cooldown).""" failure_threshold: int = 3 cooldown_seconds: float = 30.0 _failures: int = field(default=0, init=False) _opened_at: float | None = field(default=None, init=False) def before_call(self) -> None: if self._opened_at is None: return elapsed = time.monotonic() - self._opened_at if elapsed < self.cooldown_seconds: raise CircuitOpenError( f"circuito abierto, reintentar en {self.cooldown_seconds - elapsed:.1f}s" ) # El tiempo de espera (cooldown) ha transcurrido: permitir una sonda semierta a continuación. def record_success(self) -> None: self._failures = 0 self._opened_at = None def record_failure(self) -> None: self._failures += 1 if self._failures >= self.failure_threshold and self._opened_at is None: self._opened_at = time.monotonic()async def run_subagent_with_timeout(prompt: str, options: AgentOptions, *, per_message_timeout: float = 20.0): """Consume mensajes de query(), generando TimeoutError si el stream se atasca entre mensajes.""" stream = query(prompt=prompt, options=options).__aiter__() result = None while True: try: message = await asyncio.wait_for(stream.__anext__(), timeout=per_message_timeout) except StopAsyncIteration: break if message.get("type") == "subagent_result": result = message if result is None: raise RuntimeError("subagent produced no usable result") return resultasync def call_with_retry(make_call, *, max_attempts: int = 3, base_delay: float = 1.0): """Reintenta una llamada asíncrona con retroceso exponencial con jitter.""" last_error: Exception | None = None for attempt in range(1, max_attempts + 1): try: return await make_call() except (asyncio.TimeoutError, RuntimeError) as exc: last_error = exc if attempt == max_attempts: break delay = base_delay * (2 ** (attempt - 1)) + random.uniform(0, 0.5) await asyncio.sleep(delay) raise last_error# Un disyuntor por dependencia, indexado por nombre de subagente.breakers = { "deep-research": CircuitBreaker(failure_threshold=3, cooldown_seconds=30.0), "quick-search": CircuitBreaker(failure_threshold=3, cooldown_seconds=15.0),}DEEP_RESEARCH = SubagentConfig( name="deep-research", description="Ejecuta un pase de investigación exhaustivo y de varios pasos y cita fuentes.", allowed_tools=["web_search", "web_fetch"],)QUICK_SEARCH = SubagentConfig( name="quick-search", description="Ejecuta una única búsqueda web rápida y resume los principales resultados.", allowed_tools=["web_search"],)async def research_topic(topic: str) -> dict: """Intenta primero el subagente exhaustivo; recurre al barato en caso de fallo repetido.""" for name, subagent, timeout in [ ("deep-research", DEEP_RESEARCH, 60.0), ("quick-search", QUICK_SEARCH, 20.0), ]: breaker = breakers[name] try: breaker.before_call() except CircuitOpenError: print(f"[{name}] circuito abierto, saltando directamente al respaldo") continue options = AgentOptions(allowed_tools=[], subagents=[subagent]) try: result = await call_with_retry( lambda: run_subagent_with_timeout( f"Usa el subagente {name} para investigar: {topic}", options, per_message_timeout=timeout, ), max_attempts=2, ) breaker.record_success() result["served_by"] = name return result except (asyncio.TimeoutError, RuntimeError, CircuitOpenError) as exc: breaker.record_failure() print(f"[{name}] falló después de los reintentos: {exc}, intentando la siguiente ruta") continue raise RuntimeError(f"todas las rutas de investigación fallaron para el tema: {topic}")asyncio.run(research_topic("recent developments in battery recycling"))
Lo que esto demuestra:
Un tiempo de espera por mensaje envuelto alrededor del generador asíncrono de query() para capturar un subagente que se atasca sin generar nunca una excepción.
Una utilidad genérica call_with_retry que aplica retroceso exponencial con jitter a cualquier objeto awaitable, mantenida separada de la detección de atascos específica del subagente.
Un CircuitBreaker indexado por nombre de subagente, de modo que el fallo de deep-research no afecte al disyuntor independiente de quick-search.
Una escalera de respaldo que intenta primero el subagente exhaustivo y caro y recurre a uno más barato solo cuando los reintentos y el circuito de la primera ruta dicen que no.
El resultado de respaldo etiquetado con served_by, para que los llamadores puedan distinguir una respuesta degradada de la principal.
Una invocación de subagente a través de query() puede fallar de tres maneras distintas: genera una excepción (un error de herramienta, un resultado mal formado), se atasca (no llega ningún mensaje y no se genera ninguna excepción), o se completa con un resultado que es técnicamente válido pero vacío o incorrecto.
Los reintentos y la detección de atascos son mecanismos diferentes. Los reintentos responden a una excepción generada; la detección de atascos necesita un tiempo de espera explícito, ya que un subagente colgado produce silencio, no una excepción, por lo que envolver el flujo de mensajes en asyncio.wait_for es lo que convierte un atasco en algo que la lógica de reintento puede capturar.
Un disyuntor se sitúa por encima de los reintentos: no decide si una llamada tiene éxito, decide si vale la pena intentar la llamada en absoluto, basándose en el historial reciente de esa dependencia. before_call() se corta inmediatamente una vez que se cruza el umbral de fallo, por lo que un subagente claramente defectuoso deja de absorber presupuesto de reintento.
El estado semierto es lo que evita que un circuito permanezca abierto para siempre: una vez que transcurre el tiempo de espera (cooldown), la siguiente sonda semierta (half-open probe) pasa, y el resultado de esa única sonda (record_success o record_failure) decide si el circuito se cierra de nuevo o reinicia su tiempo de espera.
Una ruta de respaldo es una decisión separada tanto de los reintentos como del disyuntor: es lo que hace el orquestador una vez que ha renunciado a la ruta principal para esa llamada, ya sea porque se agotaron los reintentos o porque el circuito ya estaba abierto.
Cada capa debe poseer una preocupación distinta: los reintentos manejan fallos transitorios, la detección de atascos maneja el silencio, el disyuntor maneja una dependencia que está caída por más tiempo que una sola llamada, y el respaldo maneja lo que hace el orquestador una vez que todo eso ha fallado.
# asyncio.wait_for solo ayuda si lo aplicas a algo que realmente# se puede interrumpir entre awaits. Envolver todo el bucle `async for` solo# pone un tiempo de espera al bucle en su conjunto, no a cada mensaje individual atascado,# por lo que llama a __anext__() directamente por mensaje cuando necesitas granularidad por mensaje.stream = query(prompt=prompt, options=options).__aiter__()message = await asyncio.wait_for(stream.__anext__(), timeout=20.0)# StopAsyncIteration significa que el stream terminó normalmente, no que falló;# no dejes que caiga en la misma cláusula except que TimeoutError/RuntimeError.
Reintentar una llamada a herramienta no idempotente. Si una llamada a herramienta tiene un efecto secundario (enviar un correo electrónico, escribir un registro), reintentarla después de un tiempo de espera puede ejecutarla dos veces, ya que el primer intento podría haber tenido éxito antes de que se perdiera la respuesta. Solución: solo reintentar llamadas que sean idempotentes, o adjuntar una clave de idempotencia que la herramienta pueda usar para deduplicar.
Establecer el tiempo de espera de atasco demasiado ajustado. Un per_message_timeout más corto que la latencia normal del subagente mata llamadas legítimamente lentas pero funcionales, que luego se ven idénticas a un atasco real. Solución: establecer el tiempo de espera a partir del perfil de latencia real de la tarea, no una suposición, y dar a un subagente de estilo deep-research más margen que a uno de estilo quick-search.
Reintentar tanto en el padre como en el subagente. Si el orquestador padre reintenta una llamada fallida a un subagente, y el propio bucle interno del subagente también reintenta sus llamadas a herramientas, los fallos se acumulan en muchos más intentos y costos de los previstos. Solución: elegir una capa para poseer los reintentos de un fallo dado, generalmente el orquestador padre para fallos de delegación, y dejar que el bucle interno del subagente maneje sus propios reintentos a nivel de herramienta por separado.
Un solo disyuntor para todo. Un único disyuntor compartido entre subagentes no relacionados significa que una dependencia inestable activa el circuito para llamadas que no tuvieron nada que ver con el fallo. Solución: indexar los disyuntores por dependencia, uno por nombre de subagente o nombre de herramienta, como se muestra en el ejemplo de trabajo.
Sin jitter en los retrasos de retroceso. Retrasos exponenciales fijos sin jitter significan que muchas tareas orquestadas concurrentes reintentan en sincronía después de una interrupción compartida, creando un nuevo pico exactamente cuando la dependencia comienza a recuperarse. Solución: agregar un pequeño jitter aleatorio a cada retraso de retroceso, como lo hace call_with_retry arriba.
Respaldos silenciosos. Si un resultado de respaldo degradado se ve idéntico al resultado principal para el llamador, nadie se da cuenta de que la ruta principal está fallando hasta que es un problema mayor. Solución: etiquetar los resultados de respaldo (como served_by arriba) y registrar o alertar sobre la tasa de respaldo, no solo tragar el fallo.
Olvidar registrar el éxito en la sonda semierta. Si record_success() nunca se llama en la sonda que pasa después del tiempo de espera (cooldown), el disyuntor nunca puede cerrarse realmente incluso una vez que la dependencia se ha recuperado. Solución: siempre enrutar ambos resultados de la llamada de sonda a través de la misma ruta record_success/record_failure que una llamada normal.
¿Cuál es la diferencia entre un subagente "fallido" y un subagente "atascado"?
Un fallo genera una excepción (un error de herramienta, un resultado mal formado) que el manejo de excepciones ordinario puede capturar.
Un atasco no produce ni un resultado ni una excepción, la llamada simplemente nunca se completa, por lo que necesita un tiempo de espera explícito en lugar de un bloque try/except.
¿Cuántos intentos de reintento debo usar?
No hay un número universal, pero 2-3 intentos totales es un valor predeterminado razonable para reintentos a nivel de orquestación. Más que eso generalmente significa que el fallo no es transitorio, y un respaldo o disyuntor es la mejor respuesta que más reintentos.
¿Se debe reintentar cada llamada a herramienta?
No. Solo reintentar llamadas que sean seguras de repetir, es decir, llamadas idempotentes o aquellas con una clave de deduplicación. Reintentar una llamada con un efecto secundario no dedupicado corre el riesgo de ejecutar ese efecto secundario más de una vez.
¿Cómo se diferencia un respaldo de un reintento?
Un reintento repite la misma llamada a la misma dependencia, esperando que el fallo fuera transitorio. Un respaldo cambia a una ruta completamente diferente, un subagente más simple, una herramienta más barata, una vez que ha decidido que la ruta principal no tendrá éxito esta vez.
¿Debería un disyuntor ser compartido entre tareas orquestadas concurrentes?
Generalmente sí, por dependencia. Si diez tareas concurrentes están llamando cada una al mismo subagente, deberían compartir un disyuntor para ese subagente para que el circuito refleje la salud real de la dependencia, no la vista privada de cada tarea sobre ella.
¿Qué sucede durante el estado semierto?
Una vez que transcurre el tiempo de espera (cooldown), el disyuntor permite que pase exactamente una llamada como sonda. Si esa sonda tiene éxito, el disyuntor se cierra y reanuda las llamadas normales. Si falla, el disyuntor se reabre y comienza de nuevo el tiempo de espera.
¿Cómo detecto un atasco si query() no genera un error para uno?
Envuelve el consumo del flujo de mensajes en asyncio.wait_for, aplicado por mensaje en lugar de alrededor de todo el bucle, para que una brecha más larga que tu tiempo de espera entre mensajes genere TimeoutError que puedas capturar y tratar como un fallo.
¿Dónde deben residir los reintentos, en el orquestador padre o dentro del subagente?
En el límite que realmente observó el fallo. Los fallos de delegación (una llamada a subagente que expiró o generó un error) pertenecen a la lógica de reintento del orquestador padre. Los fallos de herramienta del propio subagente pertenecen al bucle interno de ese subagente, no duplicados por el padre.
¿Necesita el retroceso exponencial jitter?
Sí, para cualquier cosa que se ejecute con concurrencia. Sin jitter, muchos llamadores que reintentan la misma dependencia después de un fallo compartido tienden a reintentar en sincronía, produciendo un nuevo pico de carga en cada intervalo de retroceso en lugar de distribuir los reintentos.
¿Qué debería hacer realmente una ruta de respaldo?
Algo significativamente más barato, más simple o de alcance diferente que la ruta principal. Un respaldo que falla de la misma manera que el principal agrega latencia sin agregar resiliencia.
¿Cómo evito contar doble un fallo entre reintentos y el disyuntor?
Registra un fallo de disyuntor por secuencia de intentos de llamada que finalmente falló, no uno por cada reintento individual dentro de esa secuencia. El ejemplo anterior llama a record_failure() una vez, después de que call_with_retry ha agotado sus intentos, no dentro del propio bucle de reintento.
¿Es seguro validar el resultado de un subagente y tratar un "resultado erróneo" como un fallo?
Sí, y es necesario. Un subagente puede regresar normalmente con un resultado vacío o incorrecto, que no generará un error por sí solo. Validar explícitamente el resultado y generar un error si falla la validación es lo que permite que la lógica de reintento, respaldo y disyuntor lo trate igual que cualquier otro fallo.
Versiones de Stack: Escrito contra la línea de modelos de Claude actual a partir de ~junio de 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (el predeterminado), y Claude Haiku 4.5 - y el Claude Agent SDK (última versión). Los nombres de modelos, versiones de SDK y precios cambian rápidamente; verifique los detalles actuales en platform.claude.com/docs antes de confiar en ellos.
Revisado por Chris St. John·Última actualización: 16 jul 2026