Skip to content

Commit 35e682a

Browse files
author
RJ Lopez
committed
test: add regression test for #915 (catchable error on unreachable streamable-http)
1 parent 3da79fe commit 35e682a

1 file changed

Lines changed: 71 additions & 0 deletions

File tree

tests/client/test_session_group.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import contextlib
2+
import socket
23
from unittest import mock
34

45
import httpx
@@ -385,3 +386,73 @@ async def test_client_session_group_establish_session_parameterized(
385386
# 3. Assert returned values
386387
assert returned_server_info is mock_initialize_result.server_info
387388
assert returned_session is mock_entered_session
389+
390+
391+
def _free_tcp_port() -> int:
392+
"""Return a TCP port number not currently bound on localhost.
393+
394+
A small race window exists between this returning and the test
395+
using the port, but it is acceptable here: the test only requires
396+
that no streamable-http MCP server be listening at connect time.
397+
"""
398+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
399+
sock.bind(("127.0.0.1", 0))
400+
return sock.getsockname()[1]
401+
402+
403+
def _is_cancel_scope_runtime_error(exc: BaseException) -> bool:
404+
"""Walk *exc* and its cause/context/group chain looking for the
405+
``Attempted to exit cancel scope in a different task`` RuntimeError
406+
that previously masked underlying connection errors (issue #915).
407+
"""
408+
seen: set[int] = set()
409+
410+
def _walk(e: BaseException | None) -> bool:
411+
if e is None or id(e) in seen:
412+
return False
413+
seen.add(id(e))
414+
if isinstance(e, RuntimeError) and "cancel scope" in str(e).lower():
415+
return True
416+
if isinstance(e, BaseExceptionGroup):
417+
if any(_walk(child) for child in e.exceptions):
418+
return True
419+
return _walk(e.__cause__) or _walk(e.__context__)
420+
421+
return _walk(exc)
422+
423+
424+
@pytest.mark.anyio
425+
async def test_unreachable_streamable_http_error_is_catchable() -> None:
426+
"""Regression test for #915.
427+
428+
Connecting ``ClientSessionGroup`` to an unbound local port must
429+
raise a *catchable* connection error rather than being shadowed by
430+
the AnyIO cancel-scope ``RuntimeError`` from concurrent teardown.
431+
"""
432+
port = _free_tcp_port()
433+
server_params = StreamableHttpParameters(url=f"http://127.0.0.1:{port}/mcp/")
434+
435+
caught: BaseException | None = None
436+
437+
try:
438+
async with ClientSessionGroup() as group:
439+
try:
440+
await group.connect_to_server(server_params)
441+
except BaseException as inner: # noqa: BLE001
442+
# Expected post-fix: real ConnectError lands here.
443+
caught = inner
444+
except BaseException as outer: # noqa: BLE001
445+
# If we land here, the error escaped past the inner handler --
446+
# that is the regression case (masking RuntimeError surfacing
447+
# from __aexit__ instead of the real ConnectError propagating).
448+
caught = outer
449+
450+
assert caught is not None, (
451+
"Expected to catch a connection error against an unreachable "
452+
"streamable-http server, but no exception was raised."
453+
)
454+
assert not _is_cancel_scope_runtime_error(caught), (
455+
"Regression of #915: connection error against an unreachable "
456+
"streamable-http server was masked by an anyio cancel-scope "
457+
f"RuntimeError. Got: {type(caught).__name__}: {caught}"
458+
)

0 commit comments

Comments
 (0)