fix(ingest): narrow mcp_timeout to our OWN deadline, not the exception type (kø-z follow-up)
Advisor review of the prior commit (5269b7d) found the except TimeoutError
branch was wider than the brief asked for: builtin TimeoutError is also
socket.timeout (3.10+) and asyncio.TimeoutError (3.11+), so any TimeoutError
reaching that clause got relabeled mcp_timeout regardless of source. Gate on
anyio.CancelScope.cancelled_caught instead, mirroring _unwrap_ingest_error's
ownership rule (own it, wrap it; otherwise, untouched).
Measured: no live trigger exists today (MCP's own internal read-timeout
converts to McpError before reaching us; a server-side TimeoutError becomes
an ordinary isError result) -- pinned with a synthetic test raising from
StdioServerParameters construction, inside our fail_after scope but before
either nested task group, so it arrives ungrouped. Four mutations red against
the full 625-test suite: drop the translation, revert to asyncio.wait_for,
relabel the code, and drop the cancelled_caught gate.
Also promotes anyio to a declared direct dependency (was transitive via mcp
only) -- ingest_mcp.py now imports it directly.
This commit is contained in:
parent
5269b7ddd5
commit
8d1d29edf0
4 changed files with 64 additions and 10 deletions
|
|
@ -18,6 +18,7 @@ dependencies = [
|
|||
"pulp>=2.8", # deterministisk validator-solver; PuLP bundler CBC i wheelen (R2). Installert 3.3.2.
|
||||
# PuLP 4.0 vil kreve `pip install pulp[cbc]` + COIN_CMD (Fase-migrasjonsnotat).
|
||||
"mcp>=1.28.0", # tynn lokal-mappe MCP-server (Step 7) — GA (resolverte 1.28.0) per Step 1-beslutning
|
||||
"anyio>=4.14", # kø-(z): ingest_mcp.py bruker anyio.fail_after direkte (MCP-timeout-stien); allerede transitiv via mcp — promotert til deklarert direkte dep (zero new install weight, resolverte 4.14.0)
|
||||
"pydantic>=2.11,<3", # IR/validering (B1) — eksplisitt pin til STABIL 2.x, aldri alpha
|
||||
# S3.1: brute-force cosine for the hybrid verdict retriever — MAF-free, offline (D-C).
|
||||
# Upper bound is <2.3, NOT <3, and it is load-bearing twice over: numpy 2.3+ requires
|
||||
|
|
|
|||
|
|
@ -157,6 +157,12 @@ def stdio_call_tool(
|
|||
if credential is not None:
|
||||
env["MCP_CREDENTIAL"] = credential
|
||||
|
||||
# Populated with OUR OWN cancel scope once `run()` enters it — read back after
|
||||
# `asyncio.run` returns/raises, to tell "our deadline fired" apart from any other
|
||||
# `TimeoutError` (builtin `TimeoutError` is also `socket.timeout`/`asyncio.TimeoutError`
|
||||
# since 3.10/3.11, so the exception TYPE alone does not prove ownership).
|
||||
deadline: dict[str, anyio.CancelScope] = {}
|
||||
|
||||
async def run() -> str:
|
||||
# The deadline is an anyio cancel scope, not `asyncio.wait_for`, and it wraps BOTH
|
||||
# nested task groups (`stdio_client`, `ClientSession`) from the INSIDE. `wait_for`
|
||||
|
|
@ -166,7 +172,8 @@ def stdio_call_tool(
|
|||
# — because a background reader task lost its write end mid-teardown. anyio's own scope
|
||||
# is what the task groups already coordinate cancellation through, so nesting inside it
|
||||
# tears down cleanly and raises a plain `TimeoutError` at the `with` statement.
|
||||
with anyio.fail_after(timeout_seconds):
|
||||
with anyio.fail_after(timeout_seconds) as scope:
|
||||
deadline["scope"] = scope
|
||||
params = StdioServerParameters(command=command, args=list(args), env=env)
|
||||
async with stdio_client(params) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
|
|
@ -182,10 +189,20 @@ def stdio_call_tool(
|
|||
try:
|
||||
return asyncio.run(run())
|
||||
except TimeoutError as exc:
|
||||
raise IngestError(
|
||||
f"MCP tool {tool!r} on {server_ref!r} did not respond within {timeout_seconds}s",
|
||||
code="mcp_timeout",
|
||||
) from exc
|
||||
# Mirrors `_unwrap_ingest_error`'s ownership rule: own it, wrap it; otherwise,
|
||||
# untouched. Only OUR scope hitting ITS OWN deadline earns `mcp_timeout` — a
|
||||
# `TimeoutError` from elsewhere (there is no live source today: MCP's own internal
|
||||
# read-timeout converts to `McpError` before reaching us, and a server-side
|
||||
# `TimeoutError` becomes an ordinary `isError` result, both measured — but the type
|
||||
# alone does not guarantee it) is re-raised exactly as `_unwrap_ingest_error` would.
|
||||
scope = deadline.get("scope")
|
||||
if scope is not None and scope.cancelled_caught:
|
||||
raise IngestError(
|
||||
f"MCP tool {tool!r} on {server_ref!r} did not respond within "
|
||||
f"{timeout_seconds}s",
|
||||
code="mcp_timeout",
|
||||
) from exc
|
||||
raise
|
||||
except BaseException as exc: # noqa: BLE001 — re-raised unchanged unless we own it
|
||||
owned = _unwrap_ingest_error(exc)
|
||||
if owned is None:
|
||||
|
|
|
|||
|
|
@ -125,11 +125,14 @@ def test_tool_timeout_reaches_the_caller_as_ingest_error(
|
|||
) -> None:
|
||||
"""A tool that outlives ``timeout_seconds`` must surface as a typed ``IngestError``.
|
||||
|
||||
``asyncio.wait_for`` wraps ``run()`` — itself two nested anyio task groups
|
||||
(``stdio_client``, ``ClientSession``). Only a REAL hanging server proves what the timeout
|
||||
cancellation actually surfaces as: a canned-tool test never enters a task group and cannot
|
||||
observe this at all (the same reason (x)'s ``mcp_tool_error`` unwrap needed a real subprocess).
|
||||
RED until the timeout is caught and re-raised as an owned ``IngestError``.
|
||||
MEASURED (2026-08-03), not assumed: wrapping ``run()`` — itself two nested anyio task groups
|
||||
(``stdio_client``, ``ClientSession``) — in ``asyncio.wait_for`` from OUTSIDE anyio's own
|
||||
structure does not raise ``TimeoutError`` at all here. anyio's cancellation and asyncio's do
|
||||
not compose across that boundary; the actual failure was an ``anyio.BrokenResourceError``
|
||||
inside a ``BaseExceptionGroup`` (a background reader losing its write end mid-teardown). Only
|
||||
a REAL hanging server proves this: a canned-tool test never enters a task group and cannot
|
||||
observe it. RED until the deadline moves to ``anyio.fail_after``, nested INSIDE both task
|
||||
groups, where anyio tears its own structure down cleanly and raises a plain ``TimeoutError``.
|
||||
"""
|
||||
script = _write_server(
|
||||
tmp_path,
|
||||
|
|
@ -142,6 +145,37 @@ def test_tool_timeout_reaches_the_caller_as_ingest_error(
|
|||
assert excinfo.value.code == "mcp_timeout"
|
||||
|
||||
|
||||
def test_unrelated_timeout_error_inside_the_scope_is_not_mislabeled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A ``TimeoutError`` NOT caused by our own deadline firing must reach the caller untouched.
|
||||
|
||||
Mirrors ``_unwrap_ingest_error``'s ownership rule: own it, wrap it; otherwise, untouched.
|
||||
Raised from ``StdioServerParameters`` construction — inside our ``anyio.fail_after`` scope,
|
||||
but BEFORE either nested task group (``stdio_client``, ``ClientSession``) is entered, so it
|
||||
reaches our ``except`` clause as a bare ``TimeoutError``, not grouped (that grouping is a
|
||||
separate, already-covered property — see the ``mcp_tool_error`` test above). ``timeout_seconds``
|
||||
is generous (30s) and nothing here waits on it, so the scope never actually cancels: this proves
|
||||
the label tracks the SCOPE's ``cancelled_caught``, not the exception TYPE.
|
||||
|
||||
No live trigger for this exists today, checked rather than assumed: a server-side
|
||||
``TimeoutError`` becomes an ordinary ``isError`` result (same as any other tool exception,
|
||||
verified against a real server), and MCP's own internal per-request read-timeout
|
||||
(``ClientSession.send_request``) converts its ``anyio.fail_after`` timeout to ``McpError``
|
||||
before it ever reaches us. This test pins the discriminator against that defect class
|
||||
directly, since the class has no reachable path to exercise it end-to-end.
|
||||
"""
|
||||
|
||||
def boom(*args: object, **kwargs: object) -> None:
|
||||
raise TimeoutError("unrelated — not our deadline")
|
||||
|
||||
monkeypatch.setattr("mcp.StdioServerParameters", boom)
|
||||
get = _transport(GOLDEN / "server.py", monkeypatch, timeout_seconds=30.0)
|
||||
|
||||
with pytest.raises(TimeoutError, match="unrelated"):
|
||||
get(f"mcp+stdio://{_SERVER_REF}/cost_docs", None)
|
||||
|
||||
|
||||
def test_missing_server_ref_fails_before_any_process_is_spawned(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -1489,6 +1489,7 @@ dependencies = [
|
|||
{ name = "agent-framework-foundry" },
|
||||
{ name = "agent-framework-openai" },
|
||||
{ name = "agent-framework-orchestrations" },
|
||||
{ name = "anyio" },
|
||||
{ name = "azure-identity" },
|
||||
{ name = "llm-ingestion-okf" },
|
||||
{ name = "mcp" },
|
||||
|
|
@ -1511,6 +1512,7 @@ requires-dist = [
|
|||
{ name = "agent-framework-foundry", specifier = ">=1.8.2" },
|
||||
{ name = "agent-framework-openai", specifier = ">=1.8.2" },
|
||||
{ name = "agent-framework-orchestrations", specifier = ">=1.0.0" },
|
||||
{ name = "anyio", specifier = ">=4.14" },
|
||||
{ name = "azure-identity", specifier = ">=1.25" },
|
||||
{ name = "llm-ingestion-okf", git = "https://git.fromaitochitta.com/open/llm-ingestion-okf.git?rev=v0.3.1" },
|
||||
{ name = "mcp", specifier = ">=1.28.0" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue