Skip to content

Commit 2255218

Browse files
fix: increment reconnection attempt counter on clean stream disconnect
_handle_reconnection() was resetting the attempt counter to 0 whenever a reconnection succeeded at the HTTP level but the stream closed without delivering a complete response. This made MAX_RECONNECTION_ATTEMPTS ineffective: a server that repeatedly accepts then drops SSE connections would loop forever, hanging the caller indefinitely. The fix passes attempt+1 on clean-close recursion, matching the behaviour of the exception path. MAX_RECONNECTION_ATTEMPTS now bounds total attempts regardless of whether individual connection attempts succeed at the HTTP level. MAX_RECONNECTION_ATTEMPTS is also raised from 2 to 5 to give legitimate transient disconnects more headroom. Also removes the pragma: no cover on the MAX_RECONNECTION_ATTEMPTS guard now that the new test exercises it. Fixes #2393
1 parent f27d2aa commit 2255218

File tree

2 files changed

+66
-5
lines changed

2 files changed

+66
-5
lines changed

src/mcp/client/streamable_http.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747

4848
# Reconnection defaults
4949
DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry
50-
MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up
50+
MAX_RECONNECTION_ATTEMPTS = 5 # Max retry attempts before giving up
5151

5252

5353
class StreamableHTTPError(Exception):
@@ -380,7 +380,7 @@ async def _handle_reconnection(
380380
) -> None:
381381
"""Reconnect with Last-Event-ID to resume stream after server disconnect."""
382382
# Bail if max retries exceeded
383-
if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover
383+
if attempt >= MAX_RECONNECTION_ATTEMPTS:
384384
logger.debug(f"Max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded")
385385
return
386386

@@ -421,9 +421,9 @@ async def _handle_reconnection(
421421
await event_source.response.aclose()
422422
return
423423

424-
# Stream ended again without response - reconnect again (reset attempt counter)
424+
# Stream ended again without response - reconnect again
425425
logger.info("SSE stream disconnected, reconnecting...")
426-
await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, 0)
426+
await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, attempt + 1)
427427
except Exception as e: # pragma: no cover
428428
logger.debug(f"Reconnection failed: {e}")
429429
# Try to reconnect again if we still have an event ID

tests/shared/test_streamable_http.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from contextlib import asynccontextmanager
1515
from dataclasses import dataclass, field
1616
from typing import Any
17-
from unittest.mock import MagicMock
17+
from unittest.mock import MagicMock, patch
1818
from urllib.parse import urlparse
1919

2020
import anyio
@@ -2318,3 +2318,64 @@ async def test_streamable_http_client_preserves_custom_with_mcp_headers(
23182318

23192319
assert "content-type" in headers_data
23202320
assert headers_data["content-type"] == "application/json"
2321+
2322+
2323+
@pytest.mark.anyio
2324+
async def test_reconnection_attempt_counter_increments_on_clean_disconnect(
2325+
event_server: tuple[SimpleEventStore, str],
2326+
) -> None:
2327+
"""Verify that _handle_reconnection increments attempt on clean stream close.
2328+
2329+
Previously, the attempt counter was reset to 0 on clean disconnect, causing
2330+
MAX_RECONNECTION_ATTEMPTS to be ineffective and allowing infinite reconnect
2331+
loops when a server repeatedly accepts then closes the stream without responding.
2332+
2333+
With the fix (attempt+1), MAX_RECONNECTION_ATTEMPTS is respected for clean
2334+
disconnects too, and the client gives up after a bounded number of retries.
2335+
2336+
Uses tool_with_multiple_stream_closes with more checkpoints than MAX_RECONNECTION_ATTEMPTS
2337+
so that the attempt counter is exercised all the way to the limit.
2338+
"""
2339+
import mcp.client.streamable_http as streamable_http_module
2340+
2341+
_, server_url = event_server
2342+
2343+
attempts_seen: list[int] = []
2344+
original_handle_reconnection = StreamableHTTPTransport._handle_reconnection
2345+
2346+
async def spy_handle_reconnection(
2347+
self: StreamableHTTPTransport,
2348+
ctx: object,
2349+
last_event_id: str,
2350+
retry_interval_ms: int | None = None,
2351+
attempt: int = 0,
2352+
) -> None:
2353+
attempts_seen.append(attempt)
2354+
await original_handle_reconnection(self, ctx, last_event_id, retry_interval_ms, attempt)
2355+
2356+
with (
2357+
patch.object(streamable_http_module, "MAX_RECONNECTION_ATTEMPTS", 2),
2358+
patch.object(StreamableHTTPTransport, "_handle_reconnection", spy_handle_reconnection),
2359+
):
2360+
with anyio.move_on_after(8):
2361+
async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream):
2362+
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
2363+
await session.initialize()
2364+
try:
2365+
# Use more checkpoints than MAX_RECONNECTION_ATTEMPTS=2.
2366+
# Each checkpoint closes then reopens the stream. With attempt+1,
2367+
# the counter increments and hits the limit after MAX attempts.
2368+
await session.call_tool(
2369+
"tool_with_multiple_stream_closes",
2370+
{"checkpoints": 3, "sleep_time": 0.6},
2371+
)
2372+
except Exception:
2373+
pass
2374+
2375+
# With the fix: attempts seen are [0, 1, 2] — counter increments on each clean close.
2376+
# The bail at attempt=2 (>= MAX=2) covers the MAX_RECONNECTION_ATTEMPTS guard.
2377+
# Without the fix: attempts would repeat [0, 0, 0, ...] forever.
2378+
assert attempts_seen == [0, 1, 2], (
2379+
f"Expected attempt counter to increment [0, 1, 2], got {attempts_seen}. "
2380+
"This indicates the reconnect counter is not incrementing on clean disconnects."
2381+
)

0 commit comments

Comments
 (0)