El bug que dejó a mi agente meditando para siempre (asyncio.gather contra anyio)

31 October, 2026 | homelab

Serie Homelab · Parte 10 · ← Un agente que lee las noticias de IA por mí · El caché que nunca acertaba → · Ver la serie

TL;DR: Si tu agente Python se congela sin errores ni crashes, y corre sobre un framework que usa anyio (FastAPI y Starlette lo usan por debajo), revisa si tienes asyncio.gather() dentro del flujo. En mi caso, cambiarlo por un anyio.create_task_group() resolvió el problema. No te prometo la explicación completa del mecanismo; sí te cuento cómo lo encontré.

Había una vez un agente muy meditativo

Recibía una pregunta. Empezaba a pensar. Seguía pensando. Y después de 120 segundos, el tiempo límite lo mataba sin ruido, con un mensaje de error del tamaño de un post-it: Request timed out. Era el agente más zen que construí en mi vida, y también el más inútil.

Este es el post sobre ese agente y sobre los dos días que perdí con él.

El peor tipo de bug

Un crash con stack trace es un regalo: tienes la línea, el error y la causa. Un bug que simplemente no hace nada te convierte en detective sin pistas. El ciclo era este:

  1. Llega la petición.
  2. El agente la recibe (los logs lo confirman).
  3. … nada.
  4. … más nada.
  5. A los 120 segundos, un timeout lo mata.

No había excepción, ni error en el código del agente, ni nada. Ni éxito ni fracaso.

Lo que probé

Intento 1: subir el timeout. Mi razonamiento: «quizás solo necesita más tiempo». Lo subí de 120 a 300 segundos. El agente meditó 300 segundos. No era un problema de tiempo.

Intento 2: logs agresivos. Si no me habla, lo obligo a hablar. Puse un log antes y después de cada llamada asíncrona. Ahí apareció la primera pista real:

[INFO] Recibiendo request...
[INFO] Llamando a prometheus, filesystem y proxmox en paralelo...
(silencio)

El agente llegaba hasta el punto donde lanzaba las tres consultas en paralelo y no pasaba de ahí. Ni el log siguiente ni el except.

Intento 3: aislar. Probé cada servidor por separado, sin paralelismo. Funcionaban los tres, rápidos. El problema era la combinación: el paralelo, y lo que pasa cuando algo intenta cancelarlo.

El sospechoso

El código hacía esto:

import asyncio

async def consultar_en_paralelo():
    resultados = await asyncio.gather(
        llamar_prometheus(),
        llamar_filesystem(),
        llamar_proxmox(),
    )
    return resultados

Es el código que escribe todo el mundo. Funciona perfecto… hasta que alguien intenta cancelarlo.

Mi explicación, con la humildad que corresponde: los frameworks modernos (FastAPI, Starlette, httpx) usan anyio por debajo, que maneja los timeouts y las cancelaciones con sus propios cancel scopes. Y asyncio.gather() tiene su propia forma de manejar la cancelación de las tareas hijas. Mezclar los dos modelos, para mí, dejó la cancelación a medias: el timeout creía haber cancelado, el gather seguía esperando, y la corrutina nunca retornaba. Zombie.

Esa es mi hipótesis, y encaja con lo que vi. Si alguien que conozca los internos de anyio quiere corregirla, bienvenido sea.

La solución: usar las herramientas del framework en el que estás

La recomendación habitual en el mundo anyio es usar task groups en lugar de gather. Están diseñados para trabajar con sus propios cancel scopes.

import anyio

async def consultar_en_paralelo():
    resultados = {}

    async def recolectar(nombre, funcion):
        resultados[nombre] = await funcion()

    async with anyio.create_task_group() as tg:
        tg.start_soon(recolectar, "prometheus", llamar_prometheus)
        tg.start_soon(recolectar, "filesystem", llamar_filesystem)
        tg.start_soon(recolectar, "proxmox", llamar_proxmox)

    return resultados

Hice el cambio, le mandé la misma pregunta que antes lo dejaba meditando y respondió en segundos. Y el timeout, cuando lo provoqué a propósito, cancelaba de verdad.

Lo que aprendí (como QA)

  1. La ausencia de error también es un error. Un sistema que se queda en silencio es el peor reporte de bug posible. Diseña para que siempre diga algo.
  2. Pon logs antes y después de cada llamada asíncrona, no solo en los errores. El silencio entre dos logs es una pista enorme.
  3. Aísla antes de teorizar. Probar cada pieza sola me dijo en cinco minutos lo que dos días de hipótesis no.
  4. Prueba la cancelación. Nadie prueba qué pasa cuando algo se cancela. Es exactamente donde viven los bugs más feos. Provocar un timeout a propósito debería ser parte de tus pruebas.
  5. No mezcles modelos de concurrencia. Si tu framework usa anyio, quédate en anyio.

Lo que sigue

Con el agente ya respondiendo, el siguiente problema fue de velocidad: tardaba demasiado en preguntas que se repetían. Implementé un caché, y el caché tenía su propio bug, todavía más tonto y más caro. Lo cuento en la Parte 11.