Skip to content

fix(api): un timeout de MCP corta la petición en vez de colgarla (#113) - #114

Merged
g-garciac2022 merged 2 commits into
devfrom
fix/113-timeout-cuelga
Aug 16, 2026
Merged

fix(api): un timeout de MCP corta la petición en vez de colgarla (#113)#114
g-garciac2022 merged 2 commits into
devfrom
fix/113-timeout-cuelga

Conversation

@g-garciac2022

Copy link
Copy Markdown
Collaborator

#113 · Un timeout de MCP colgaba la petición en vez de fallar

Closes #113

Descubierto al validar #107 por el protocolo: una herramienta que tarda más que
su timeout no producía un error, dejaba la petición colgada para siempre.

servidor MCP   tool.invoke  duration_ms=151326  success=True
API            sin respuesta · 6 min con la conexión abierta · 0 % de CPU
cliente        ningún código HTTP

La tool terminó bien a los 151 s. El corte configurado eran 60. La API nunca
devolvió nada.

Por qué es peor que un timeout

Un error se enseña, el usuario reintenta y el hueco de conexión se libera. Una
petición que no vuelve deja el navegador esperando indefinidamente y ocupa un
worker. Es un modo de fallo distinto — y era justo el que el ajuste pretendía
evitar.

Afectaba también al catálogo, cuyo comentario prometía lo que no cumplía: «sin
él, un servidor que acepta la conexión y no responde dejaría /tools colgado»
.
Lo que sí funcionaba era el servidor caído —conexión rechazada, falla
rápido—; el lento es otro caso y no estaba cubierto.

La causa

Qué mide
timeout de httpx (el que había) inactividad entre bytes
asyncio.timeout (el que faltaba) duración total

Con una tool lenta que no envía nada mientras trabaja, el primero no salta.
Reproducido sin modelos ni red, con una tool que duerme 10 s y un corte de 2:
25 s esperando hasta que un vigilante externo lo mató. Con asyncio.timeout,
corta a los 2,1 s.

Los dos se conservan: cubren fallos distintos y hacen falta los dos cortes.

Qué incluye

  • execute.py: asyncio.timeout acotando la operación entera —handshake,
    catálogo y llamada— más ToolTimeout como categoría propia.
  • app.py: traducción a 504.
  • catalog.py: el mismo corte, para que un servidor lento salga
    unreachable con degraded: true en vez de colgar /tools.
  • mcp_session.py: noqa del ASYNC109 con su razón.
  • ruff.toml: target-version = "py312" y el silenciado de UP042.
  • tests/api/test_timeout.py (9 tests, CI) y
    tests/integration/test_timeout_real.py (2, a mano).

504 y no status: error

Categoría nueva junto al 404 y el 422, y no un ExecuteResponse con estado de
error. El motivo está medido: al agotarse la espera la herramienta puede haber
terminado bien
— de hecho terminó. Decir que «el análisis falló» sería mentir;
un 504 dice que está tardando demasiado, que es lo que ocurre.

except*, y por qué no vale un except normal

Lo que sale de una sesión MCP viene envuelto dos veces, un task group de anyio
por capa:

ExceptionGroup: 'unhandled errors in a TaskGroup'
  ExceptionGroup: 'unhandled errors in a TaskGroup'
    TimeoutError

Ese envoltorio no se puede desactivar: es la semántica de los task groups,
donde pueden fallar varias tareas a la vez y no existe «la» excepción que
devolver. Se usa except* (Python 3.11), que compara por tipo a cualquier
profundidad
, así que no depende de cuántas capas ponga la librería mañana — un
test lo fija con 0, 1, 2 y 3 niveles.

Se descartó recorrer el árbol a mano, y la alternativa queda escrita en el código:
except* puede entrar en varias ramas, así que un timeout acompañado de otro
fallo saldría como 500 en vez de 504. Se asume.

Dos tests, porque uno solo no basta

El rápido sustituye la sesión por una que lanza el error ya fabricado: corre
en milisegundos, entra en el CI y verifica la traducción. Pero si
asyncio.timeout no cortara, seguiría pasando igual.

El fielintegration, fuera del CI— levanta un servidor MCP con una tool
lenta y comprueba que la llamada termina. Ése prueba el mecanismo.

El linter tenía la respuesta y le faltaba una línea

Al declarar target-version = "py312" saltó esto:

ASYNC109  open_session(url, timeout: float)
          help: Use `asyncio.timeout` instead

La regla ASYNC añadida en #103 señalaba este mismo bug y no podía decirlo:
asyncio.timeout() existe desde 3.11, así que ruff no lo recomienda si no sabe a
qué versión apuntas. Una línea de configuración separaba al proyecto de un aviso
automático de algo que costó una tarde encontrar a mano.

Notas

  • El corte está acotado pero no es exacto: 2,1 s con el bucle del servidor
    libre, 4,0 s con él bloqueado, para un presupuesto de 2. Al agotarse, cerrar la
    sesión aún necesita que el servidor conteste. Lo que importa es que esté
    acotado; el test fija una cota superior, no un número clavado.
  • Sigue apareciendo Session termination failed al cortar: el DELETE /mcp
    de despedida no llega a completarse. No cuelga, pero deja la sesión sin cerrar
    limpiamente en el servidor. No se persigue aquí.
  • UP042 queda silenciado, no resuelto: cambiar los ocho enums a StrEnum
    altera lo que devuelve str(Dimension.FORMA) y exige repasar los puntos de
    uso. Va a [chore] Limpieza tras el repaso de estructura: código muerto, import con efecto colateral, renombrados y docstrings #108.
  • La lentitud de fondo sigue siendo de H4: esto hace que el sistema responda
    cuando se supera el límite, no que deje de superarse. Para eso está el
    calentamiento al arrancar.

@g-garciac2022
g-garciac2022 merged commit b895fc1 into dev Aug 16, 2026
1 check passed
@g-garciac2022
g-garciac2022 deleted the fix/113-timeout-cuelga branch August 16, 2026 16:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] Una tool MCP que excede su timeout cuelga la petición en vez de fallar

1 participant