fix(api): un timeout de MCP corta la petición en vez de colgarla (#113) - #114
Merged
Conversation
…ent hanging (+ ruffl version defined)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#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.
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
/toolscolgado».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
timeoutde httpx (el que había)asyncio.timeout(el que faltaba)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.timeoutacotando la operación entera —handshake,catálogo y llamada— más
ToolTimeoutcomo categoría propia.app.py: traducción a 504.catalog.py: el mismo corte, para que un servidor lento salgaunreachablecondegraded: trueen vez de colgar/tools.mcp_session.py:noqadelASYNC109con su razón.ruff.toml:target-version = "py312"y el silenciado deUP042.tests/api/test_timeout.py(9 tests, CI) ytests/integration/test_timeout_real.py(2, a mano).504 y no
status: errorCategoría nueva junto al 404 y el 422, y no un
ExecuteResponsecon estado deerror. 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 unexceptnormalLo que sale de una sesión MCP viene envuelto dos veces, un task group de anyio
por capa:
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 cualquierprofundidad, 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 otrofallo 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.timeoutno cortara, seguiría pasando igual.El fiel —
integration, fuera del CI— levanta un servidor MCP con una toollenta 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:La regla
ASYNCañ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 aqué 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
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.
Session termination failedal cortar: elDELETE /mcpde despedida no llega a completarse. No cuelga, pero deja la sesión sin cerrar
limpiamente en el servidor. No se persigue aquí.
UP042queda silenciado, no resuelto: cambiar los ocho enums aStrEnumaltera lo que devuelve
str(Dimension.FORMA)y exige repasar los puntos deuso. Va a [chore] Limpieza tras el repaso de estructura: código muerto, import con efecto colateral, renombrados y docstrings #108.
cuando se supera el límite, no que deje de superarse. Para eso está el
calentamiento al arrancar.