Skip to content

Commit 4c04b92

Browse files
committed
fix(client): count clean-EOF reconnects toward the request retry budget
_handle_reconnection recurses with attempt=0 on the clean-EOF path (stream closed after emitting only a priming event, no JSON-RPC response). The exception path correctly passes attempt+1, but the normal EOF path resets the counter. A no-timeout caller such as subscriptions/listen can therefore reconnect indefinitely instead of resolving with CONNECTION_CLOSED after MAX_RECONNECTION_ATTEMPTS. Pass attempt+1 on both paths so clean EOFs and transport exceptions consume the same budget. Fixes #3307
1 parent d2290ca commit 4c04b92

2 files changed

Lines changed: 52 additions & 2 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -525,9 +525,9 @@ async def _handle_reconnection(
525525
await event_source.response.aclose()
526526
return
527527

528-
# Stream ended again without response - reconnect again (reset attempt counter)
528+
# Stream ended without delivering a JSON-RPC response — count toward the budget.
529529
logger.info("SSE stream disconnected, reconnecting...")
530-
await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, 0)
530+
await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, attempt + 1)
531531
except Exception as e: # pragma: no cover
532532
logger.debug(f"Reconnection failed: {e}")
533533
# Try to reconnect again if we still have an event ID

tests/client/test_streamable_http.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,3 +748,53 @@ async def test_resolving_an_abandoned_request_after_the_reader_closed_is_contain
748748
_abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS
749749
)
750750
send.close()
751+
752+
753+
class _PrimingOnlySSEStream(httpx2.AsyncByteStream):
754+
"""Emits one id-bearing priming event then EOF — a resumable stream that
755+
never delivers a JSON-RPC response."""
756+
757+
_counter = 0
758+
759+
def __init__(self) -> None:
760+
_PrimingOnlySSEStream._counter += 1
761+
self._id = f"evt-{_PrimingOnlySSEStream._counter}"
762+
763+
async def __aiter__(self) -> AsyncIterator[bytes]:
764+
yield f"id: {self._id}\ndata: \n\n".encode()
765+
766+
async def aclose(self) -> None:
767+
pass
768+
769+
770+
@pytest.mark.anyio
771+
async def test_clean_eof_without_response_counts_toward_reconnection_budget() -> None:
772+
"""A resumable stream that reaches EOF without delivering a JSON-RPC response
773+
must consume the reconnection budget — MAX_RECONNECTION_ATTEMPTS total HTTP
774+
requests, not an unbounded sequence of resets."""
775+
_PrimingOnlySSEStream._counter = 0
776+
request_count = 0
777+
778+
def handler(request: httpx2.Request) -> httpx2.Response:
779+
nonlocal request_count
780+
request_count += 1
781+
return httpx2.Response(
782+
200,
783+
headers={"content-type": "text/event-stream"},
784+
stream=_PrimingOnlySSEStream(),
785+
)
786+
787+
transport = StreamableHTTPTransport("http://test/mcp")
788+
send, receive = create_context_streams[SessionMessage | Exception](1)
789+
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
790+
with anyio.fail_after(5):
791+
await transport._handle_reconnection( # pyright: ignore[reportPrivateUsage]
792+
_abandoned_request_context(http, send), "evt-0", 0
793+
)
794+
reply = await receive.receive()
795+
assert isinstance(reply, SessionMessage)
796+
assert isinstance(reply.message, JSONRPCError)
797+
assert reply.message.error.code == CONNECTION_CLOSED
798+
assert request_count == MAX_RECONNECTION_ATTEMPTS
799+
send.close()
800+
receive.close()

0 commit comments

Comments
 (0)