Skip to content

Commit b1ebd7d

Browse files
committed
fix: Narrow retry exceptions and unwrap StreamableHTTPError at client boundary
1 parent 762dfb3 commit b1ebd7d

2 files changed

Lines changed: 145 additions & 36 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 59 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,21 @@ class ResumptionError(StreamableHTTPError):
6262
"""Raised when resumption request is invalid."""
6363

6464

65+
def _unwrap_exception(exc: BaseException) -> BaseException:
66+
"""Recursively find and return the first StreamableHTTPError in an exception's tree/group."""
67+
if isinstance(exc, StreamableHTTPError):
68+
return exc
69+
70+
exceptions = getattr(exc, "exceptions", None)
71+
if exceptions is not None:
72+
for sub_exc in exceptions:
73+
unwrapped = _unwrap_exception(sub_exc)
74+
if isinstance(unwrapped, StreamableHTTPError):
75+
return unwrapped
76+
return exc
77+
78+
79+
6580
@dataclass
6681
class RequestContext:
6782
"""Context for a request operation."""
@@ -228,11 +243,12 @@ async def handle_get_stream(self, client: httpx2.AsyncClient, read_stream_writer
228243
# Stream ended normally (server closed) - reset attempt counter
229244
attempt = 0
230245

231-
except Exception as exc:
246+
except httpx2.HTTPError as exc:
232247
logger.debug("GET stream error", exc_info=True)
233248
attempt += 1
234249
last_exc = exc
235250

251+
236252
if attempt >= MAX_RECONNECTION_ATTEMPTS:
237253
logger.debug(f"GET stream max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded")
238254
raise StreamableHTTPError(
@@ -448,9 +464,10 @@ async def _handle_sse_response(
448464
if is_complete:
449465
await response.aclose()
450466
return # Normal completion, no reconnect needed
451-
except Exception:
467+
except httpx2.HTTPError:
452468
logger.debug("SSE stream ended", exc_info=True) # pragma: lax no cover
453469

470+
454471
# Stream ended without response - reconnect if we received an event with ID
455472
if last_event_id is not None:
456473
logger.info("SSE stream disconnected, reconnecting...")
@@ -532,11 +549,12 @@ async def _handle_reconnection(
532549
# Stream ended again without response - reconnect again (reset attempt counter)
533550
logger.info("SSE stream disconnected, reconnecting...")
534551
await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, 0)
535-
except Exception as e: # pragma: no cover
552+
except httpx2.HTTPError as e: # pragma: no cover
536553
logger.debug(f"Reconnection failed: {e}")
537554
# Try to reconnect again if we still have an event ID
538555
await self._handle_reconnection(ctx, last_event_id, retry_interval_ms, attempt + 1)
539556

557+
540558
async def post_writer(
541559
self,
542560
client: httpx2.AsyncClient,
@@ -684,39 +702,46 @@ async def streamable_http_client(
684702

685703
logger.debug(f"Connecting to StreamableHTTP endpoint: {url}")
686704

687-
async with contextlib.AsyncExitStack() as stack:
688-
# Only manage client lifecycle if we created it
689-
if not client_provided:
690-
await stack.enter_async_context(client)
691-
692-
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
693-
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)
705+
try:
706+
async with contextlib.AsyncExitStack() as stack:
707+
# Only manage client lifecycle if we created it
708+
if not client_provided:
709+
await stack.enter_async_context(client)
694710

695-
async with (
696-
read_stream_writer,
697-
read_stream,
698-
write_stream,
699-
write_stream_reader,
700-
anyio.create_task_group() as tg,
701-
):
711+
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
712+
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)
702713

703-
def start_get_stream() -> None:
704-
tg.start_soon(transport.handle_get_stream, client, read_stream_writer)
705-
706-
tg.start_soon(
707-
transport.post_writer,
708-
client,
709-
write_stream_reader,
714+
async with (
710715
read_stream_writer,
716+
read_stream,
711717
write_stream,
712-
start_get_stream,
713-
tg,
714-
)
718+
write_stream_reader,
719+
anyio.create_task_group() as tg,
720+
):
721+
722+
def start_get_stream() -> None:
723+
tg.start_soon(transport.handle_get_stream, client, read_stream_writer)
724+
725+
tg.start_soon(
726+
transport.post_writer,
727+
client,
728+
write_stream_reader,
729+
read_stream_writer,
730+
write_stream,
731+
start_get_stream,
732+
tg,
733+
)
734+
735+
try:
736+
yield read_stream, write_stream
737+
finally:
738+
if transport.session_id and terminate_on_close:
739+
await transport.terminate_session(client)
740+
tg.cancel_scope.cancel()
741+
await resync_tracer()
742+
except BaseException as exc:
743+
unwrapped = _unwrap_exception(exc)
744+
if isinstance(unwrapped, StreamableHTTPError):
745+
raise unwrapped from exc
746+
raise
715747

716-
try:
717-
yield read_stream, write_stream
718-
finally:
719-
if transport.session_id and terminate_on_close:
720-
await transport.terminate_session(client)
721-
tg.cancel_scope.cancel()
722-
await resync_tracer()

tests/shared/test_streamable_http.py

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2299,8 +2299,8 @@ async def test_reconnect_failure_propagates_error() -> None:
22992299
# Create a context-aware stream writer (matches StreamWriter type alias)
23002300
write_stream, read_stream = create_context_streams[SessionMessage | Exception](1)
23012301

2302-
# Mock client.sse to raise an exception
2303-
client.sse.side_effect = Exception("Connection refused")
2302+
# Mock client.sse to raise a connection error
2303+
client.sse.side_effect = httpx2.ConnectError("Connection refused")
23042304

23052305
# Patch anyio.sleep to avoid waiting
23062306
with patch("mcp.client.streamable_http.anyio.sleep", new_callable=AsyncMock) as mock_sleep:
@@ -2316,3 +2316,87 @@ async def test_reconnect_failure_propagates_error() -> None:
23162316

23172317
await write_stream.aclose()
23182318
await read_stream.aclose()
2319+
2320+
2321+
@pytest.mark.anyio
2322+
async def test_streamable_http_client_reconnect_failure_propagates_error() -> None:
2323+
"""streamable_http_client context manager should propagate StreamableHTTPError
2324+
when the GET stream connection fails completely (max attempts exceeded).
2325+
"""
2326+
client = AsyncMock(spec=httpx2.AsyncClient)
2327+
2328+
# Mock post_writer requests:
2329+
# 1. initialize request -> returns response with session ID
2330+
# 2. notifications/initialized -> returns 202 Accepted
2331+
mock_response = AsyncMock(spec=httpx2.Response)
2332+
mock_response.status_code = 200
2333+
mock_response.headers = {
2334+
"content-type": "application/json",
2335+
"mcp-session-id": "test-session",
2336+
}
2337+
mock_response.aread.return_value = json.dumps({
2338+
"jsonrpc": "2.0",
2339+
"id": 1,
2340+
"result": {
2341+
"protocolVersion": "2025-06-18",
2342+
"capabilities": {},
2343+
"serverInfo": {"name": "test-server", "version": "1.0"},
2344+
}
2345+
}).encode("utf-8")
2346+
2347+
mock_initialized_response = AsyncMock(spec=httpx2.Response)
2348+
mock_initialized_response.status_code = 202
2349+
2350+
# Use asynccontextmanager to mock client.stream
2351+
responses = [mock_response, mock_initialized_response]
2352+
2353+
@asynccontextmanager
2354+
async def mock_stream(*args: Any, **kwargs: Any):
2355+
yield responses.pop(0)
2356+
2357+
client.stream = mock_stream
2358+
2359+
2360+
# Mock client.sse to raise httpx2.HTTPError
2361+
client.sse.side_effect = httpx2.HTTPError("SSE connection refused")
2362+
2363+
# Patch anyio.sleep to avoid waiting during reconnect attempts
2364+
with patch("mcp.client.streamable_http.anyio.sleep", new_callable=AsyncMock) as mock_sleep:
2365+
with pytest.raises(StreamableHTTPError) as exc_info:
2366+
with anyio.fail_after(5):
2367+
async with streamable_http_client("http://localhost:8000/mcp", http_client=client) as (read_stream, write_stream):
2368+
# Send initialize message
2369+
await write_stream.send(SessionMessage(
2370+
types.JSONRPCRequest(
2371+
jsonrpc="2.0",
2372+
id=1,
2373+
method="initialize",
2374+
params={
2375+
"protocolVersion": "2025-06-18",
2376+
"capabilities": {},
2377+
"clientInfo": {"name": "test-client", "version": "1.0"},
2378+
},
2379+
)
2380+
))
2381+
2382+
# Receive the response
2383+
await read_stream.receive()
2384+
2385+
# Send notifications/initialized (which will trigger start_get_stream)
2386+
await write_stream.send(SessionMessage(
2387+
types.JSONRPCNotification(
2388+
jsonrpc="2.0",
2389+
method="notifications/initialized",
2390+
)
2391+
))
2392+
2393+
# Wait for the task group to fail
2394+
event = anyio.Event()
2395+
await event.wait()
2396+
2397+
2398+
assert "Failed to connect to GET stream" in str(exc_info.value)
2399+
assert client.sse.call_count == 2
2400+
mock_sleep.assert_called_once_with(1.0)
2401+
2402+

0 commit comments

Comments
 (0)