Skip to content

Commit 14e7118

Browse files
committed
fix(streamable-http): surface transport faults to callers and message_handler
When a streamable-http response stream dies with a transport error, the exception was swallowed: the pending request got a generic CONNECTION_CLOSED error and the fault never reached message_handler. Forward the exception into the read stream as an Exception item and carry it into the synthesized error messages on both the immediate and reconnect-exhausted paths.
1 parent fc41dd5 commit 14e7118

2 files changed

Lines changed: 84 additions & 12 deletions

File tree

‎src/mcp/client/streamable_http.py‎

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,7 @@ async def _handle_sse_response(
422422
assert isinstance(ctx.session_message.message, JSONRPCRequest)
423423
original_request_id = ctx.session_message.message.id
424424

425+
stream_fault: Exception | None = None
425426
try:
426427
event_source = EventSource(response)
427428
async for sse in event_source: # pragma: no branch
@@ -444,19 +445,27 @@ async def _handle_sse_response(
444445
if is_complete:
445446
await response.aclose()
446447
return # Normal completion, no reconnect needed
447-
except Exception:
448+
except Exception as exc:
449+
stream_fault = exc
448450
logger.debug("SSE stream ended", exc_info=True) # pragma: lax no cover
449451

452+
if stream_fault is not None:
453+
# Surface the transport fault to the session's message_handler as an
454+
# Exception item (see IncomingMessage); the waiter itself is resolved
455+
# below with a synthesized error carrying the same exception.
456+
await self._forward_stream_fault(ctx.read_stream_writer, stream_fault)
457+
450458
# Stream ended without response - reconnect if we received an event with ID
451459
if last_event_id is not None:
452460
logger.info("SSE stream disconnected, reconnecting...")
453-
await self._handle_reconnection(ctx, last_event_id, retry_interval_ms)
461+
await self._handle_reconnection(ctx, last_event_id, retry_interval_ms, last_exc=stream_fault)
454462
else:
455463
# Not resumable: resolve the waiter, else a listen stream's consumer
456464
# would hang forever instead of learning the subscription is lost.
457-
await self._resolve_abandoned_request(
458-
ctx.read_stream_writer, original_request_id, "SSE stream ended without a response"
459-
)
465+
message = "SSE stream ended without a response"
466+
if stream_fault is not None:
467+
message = f"{message}: {stream_fault!r}"
468+
await self._resolve_abandoned_request(ctx.read_stream_writer, original_request_id, message)
460469

461470
async def _resolve_abandoned_request(
462471
self, read_stream_writer: StreamWriter, request_id: RequestId, message: str, *, code: int = CONNECTION_CLOSED
@@ -472,12 +481,27 @@ async def _resolve_abandoned_request(
472481
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
473482
logger.debug("read stream closed before request %r could be resolved", request_id)
474483

484+
async def _forward_stream_fault(self, read_stream_writer: StreamWriter, exc: Exception) -> None:
485+
"""Forward a transport-level fault to the session as an Exception item.
486+
487+
The dispatcher routes Exception items to the session's message_handler via
488+
its on_stream_exception observer (see IncomingMessage); pending request
489+
waiters are resolved separately by the caller with a synthesized error
490+
carrying the same exception.
491+
"""
492+
try:
493+
await read_stream_writer.send(exc)
494+
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
495+
logger.debug("read stream closed before transport fault %r could be forwarded", exc)
496+
475497
async def _handle_reconnection(
476498
self,
477499
ctx: RequestContext,
478500
last_event_id: str,
479501
retry_interval_ms: int | None = None,
480502
attempt: int = 0,
503+
*,
504+
last_exc: Exception | None = None,
481505
) -> None:
482506
"""Reconnect with Last-Event-ID to resume stream after server disconnect."""
483507
# Only requests reconnect: every caller arrives from a request's response stream.
@@ -488,9 +512,10 @@ async def _handle_reconnection(
488512
# Resolve on give-up: a request with no read timeout (a listen
489513
# stream) would otherwise hang its caller forever.
490514
logger.debug(f"Max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded")
491-
await self._resolve_abandoned_request(
492-
ctx.read_stream_writer, original_request_id, "SSE stream ended and reconnection attempts were exhausted"
493-
)
515+
message = "SSE stream ended and reconnection attempts were exhausted"
516+
if last_exc is not None:
517+
message = f"{message}: {last_exc!r}"
518+
await self._resolve_abandoned_request(ctx.read_stream_writer, original_request_id, message)
494519
return
495520

496521
# Always wait - use server value or default
@@ -527,11 +552,11 @@ async def _handle_reconnection(
527552

528553
# Stream ended again without response - reconnect again (reset attempt counter)
529554
logger.info("SSE stream disconnected, reconnecting...")
530-
await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, 0)
555+
await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, 0, last_exc=last_exc)
531556
except Exception as e: # pragma: no cover
532557
logger.debug(f"Reconnection failed: {e}")
533558
# Try to reconnect again if we still have an event ID
534-
await self._handle_reconnection(ctx, last_event_id, retry_interval_ms, attempt + 1)
559+
await self._handle_reconnection(ctx, last_event_id, retry_interval_ms, attempt + 1, last_exc=e)
535560

536561
async def post_writer(
537562
self,

‎tests/client/test_streamable_http.py‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -611,7 +611,8 @@ async def aclose(self) -> None:
611611
@pytest.mark.anyio
612612
async def test_a_non_resumable_sse_drop_resolves_the_request_with_an_error() -> None:
613613
"""A per-request SSE stream that dies having carried no event ids can never deliver its
614-
response; the transport resolves the waiter with CONNECTION_CLOSED instead of hanging forever."""
614+
response; the transport surfaces the fault as an Exception item and resolves the waiter
615+
with CONNECTION_CLOSED carrying the exception instead of hanging forever."""
615616
dying = _DyingSSEStream()
616617

617618
def handler(request: httpx2.Request) -> httpx2.Response:
@@ -625,11 +626,52 @@ def handler(request: httpx2.Request) -> httpx2.Response:
625626
await write.send(
626627
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}))
627628
)
629+
fault = await read.receive()
628630
reply = await read.receive()
631+
assert isinstance(fault, httpx2.ReadError)
629632
assert isinstance(reply, SessionMessage)
630633
assert isinstance(reply.message, JSONRPCError)
631634
assert reply.message.id == "listen-1"
632635
assert reply.message.error.code == CONNECTION_CLOSED
636+
assert "connection reset" in reply.message.error.message
637+
638+
639+
class _DyingIdSSEStream(httpx2.AsyncByteStream):
640+
"""Yields an event id with a zero retry, then dies every time it is iterated."""
641+
642+
async def __aiter__(self) -> AsyncIterator[bytes]:
643+
yield b"id: evt-7\nretry: 0\n\n"
644+
raise httpx2.ReadError("connection reset")
645+
646+
async def aclose(self) -> None:
647+
pass
648+
649+
650+
@pytest.mark.anyio
651+
async def test_an_id_bearing_stream_that_dies_surfaces_the_fault_and_resolves_the_request() -> None:
652+
"""A resumable stream whose reconnection attempts all fail forwards the transport fault as
653+
an Exception item and resolves the waiter with CONNECTION_CLOSED carrying the last failure
654+
instead of hanging forever."""
655+
656+
def handler(request: httpx2.Request) -> httpx2.Response:
657+
return httpx2.Response(200, headers={"content-type": "text/event-stream"}, stream=_DyingIdSSEStream())
658+
659+
with anyio.fail_after(5):
660+
async with (
661+
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
662+
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
663+
):
664+
await write.send(
665+
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}))
666+
)
667+
fault = await read.receive()
668+
reply = await read.receive()
669+
assert isinstance(fault, httpx2.ReadError)
670+
assert isinstance(reply, SessionMessage)
671+
assert isinstance(reply.message, JSONRPCError)
672+
assert reply.message.id == "listen-1"
673+
assert reply.message.error.code == CONNECTION_CLOSED
674+
assert "connection reset" in reply.message.error.message
633675

634676

635677
class _DeliverOnCommandSSEStream(httpx2.AsyncByteStream):
@@ -725,13 +767,18 @@ async def test_exhausted_reconnection_attempts_resolve_the_request_with_an_error
725767
async with httpx2.AsyncClient() as http:
726768
with anyio.fail_after(5):
727769
await transport._handle_reconnection( # pyright: ignore[reportPrivateUsage]
728-
_abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS
770+
_abandoned_request_context(http, send),
771+
"evt-7",
772+
None,
773+
MAX_RECONNECTION_ATTEMPTS,
774+
last_exc=httpx2.ReadError("connection reset"),
729775
)
730776
reply = await receive.receive()
731777
assert isinstance(reply, SessionMessage)
732778
assert isinstance(reply.message, JSONRPCError)
733779
assert reply.message.id == "listen-1"
734780
assert reply.message.error.code == CONNECTION_CLOSED
781+
assert "connection reset" in reply.message.error.message
735782
send.close()
736783
receive.close()
737784

0 commit comments

Comments
 (0)