Skip to content

Commit 61262af

Browse files
committed
fix(client): avoid stack growth during SSE reconnection
1 parent d7845c0 commit 61262af

2 files changed

Lines changed: 120 additions & 54 deletions

File tree

‎src/mcp/client/streamable_http.py‎

Lines changed: 61 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -545,66 +545,73 @@ async def _handle_reconnection(
545545
assert isinstance(ctx.session_message.message, JSONRPCRequest)
546546
original_request_id = ctx.session_message.message.id
547547

548-
if attempt >= MAX_RECONNECTION_ATTEMPTS:
549-
# Resolve on give-up: a request with no read timeout (a listen
550-
# stream) would otherwise hang its caller forever.
551-
logger.debug(f"Max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded")
552-
await self._resolve_abandoned_request(
553-
ctx.read_stream_writer, original_request_id, "SSE stream ended and reconnection attempts were exhausted"
554-
)
555-
return
548+
while True:
549+
if attempt >= MAX_RECONNECTION_ATTEMPTS:
550+
# Resolve on give-up: a request with no read timeout (a listen
551+
# stream) would otherwise hang its caller forever.
552+
logger.debug(f"Max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded")
553+
await self._resolve_abandoned_request(
554+
ctx.read_stream_writer,
555+
original_request_id,
556+
"SSE stream ended and reconnection attempts were exhausted",
557+
)
558+
return
556559

557-
# Always wait - use server value or default
558-
delay_ms = retry_interval_ms if retry_interval_ms is not None else DEFAULT_RECONNECTION_DELAY_MS
559-
await anyio.sleep(delay_ms / 1000.0)
560+
# Always wait - use server value or default
561+
delay_ms = retry_interval_ms if retry_interval_ms is not None else DEFAULT_RECONNECTION_DELAY_MS
562+
await anyio.sleep(delay_ms / 1000.0)
560563

561-
headers = self._prepare_headers()
562-
headers[LAST_EVENT_ID] = last_event_id
564+
headers = self._prepare_headers()
565+
headers[LAST_EVENT_ID] = last_event_id
563566

564-
is_sse_response = False
565-
try:
566-
async with sse_within_origin(
567-
ctx.client, self.url, headers=headers, max_event_size=self.max_sse_event_size
568-
) as event_source:
569-
event_source.response.raise_for_status()
570-
content_type = event_source.response.headers.get("content-type", "").partition(";")[0]
571-
is_sse_response = content_type.strip().lower() == "text/event-stream"
572-
logger.info("Reconnected to SSE stream")
573-
574-
# Track for potential further reconnection
575-
reconnect_last_event_id: str = last_event_id
576-
reconnect_retry_ms = retry_interval_ms
577-
578-
async for sse in event_source:
579-
if sse.id: # pragma: no branch
580-
reconnect_last_event_id = sse.id
581-
if sse.retry is not None:
582-
reconnect_retry_ms = sse.retry
567+
is_sse_response = False
568+
try:
569+
async with sse_within_origin(
570+
ctx.client, self.url, headers=headers, max_event_size=self.max_sse_event_size
571+
) as event_source:
572+
event_source.response.raise_for_status()
573+
content_type = event_source.response.headers.get("content-type", "").partition(";")[0]
574+
is_sse_response = content_type.strip().lower() == "text/event-stream"
575+
logger.info("Reconnected to SSE stream")
583576

584-
is_complete = await self._handle_sse_event(
585-
sse,
586-
ctx.read_stream_writer,
587-
original_request_id,
588-
ctx.metadata.on_resumption_token_update if ctx.metadata else None,
577+
# Track for potential further reconnection
578+
reconnect_last_event_id: str = last_event_id
579+
reconnect_retry_ms = retry_interval_ms
580+
581+
async for sse in event_source:
582+
if sse.id: # pragma: no branch
583+
reconnect_last_event_id = sse.id
584+
if sse.retry is not None:
585+
reconnect_retry_ms = sse.retry
586+
587+
is_complete = await self._handle_sse_event(
588+
sse,
589+
ctx.read_stream_writer,
590+
original_request_id,
591+
ctx.metadata.on_resumption_token_update if ctx.metadata else None,
592+
)
593+
if is_complete:
594+
await event_source.response.aclose()
595+
return
596+
597+
# Stream ended again without response - reconnect again (reset attempt counter)
598+
logger.info("SSE stream disconnected, reconnecting...")
599+
except httpx2.SSEError as exc:
600+
if is_sse_response:
601+
await self._resolve_abandoned_request(
602+
ctx.read_stream_writer, original_request_id, f"SSE stream failed: {exc}"
589603
)
590-
if is_complete:
591-
await event_source.response.aclose()
592-
return
604+
return
605+
attempt += 1
606+
continue
607+
except Exception as e: # pragma: no cover
608+
logger.debug(f"Reconnection failed: {e}")
609+
attempt += 1
610+
continue
593611

594-
# Stream ended again without response - reconnect again (reset attempt counter)
595-
logger.info("SSE stream disconnected, reconnecting...")
596-
await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, 0)
597-
except httpx2.SSEError as exc:
598-
if is_sse_response:
599-
await self._resolve_abandoned_request(
600-
ctx.read_stream_writer, original_request_id, f"SSE stream failed: {exc}"
601-
)
602-
else:
603-
await self._handle_reconnection(ctx, last_event_id, retry_interval_ms, attempt + 1)
604-
except Exception as e: # pragma: no cover
605-
logger.debug(f"Reconnection failed: {e}")
606-
# Try to reconnect again if we still have an event ID
607-
await self._handle_reconnection(ctx, last_event_id, retry_interval_ms, attempt + 1)
612+
last_event_id = reconnect_last_event_id
613+
retry_interval_ms = reconnect_retry_ms
614+
attempt = 0
608615

609616
async def post_writer(
610617
self,

‎tests/client/test_streamable_http.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,3 +1279,62 @@ async def test_https_endpoint_redirected_to_plain_http_elsewhere_never_suggests_
12791279
The server is likely behind a TLS-terminating proxy whose forwarded headers it does not trust,
12801280
often combined with a trailing-slash difference. Try https://backend.lan:8000/mcp/ instead, or fix the proxy settings.\
12811281
""")
1282+
1283+
1284+
@pytest.mark.anyio
1285+
async def test_productive_reconnections_deliver_the_response_beyond_python_stack_depth() -> None:
1286+
"""SDK-defined: productive resumptions must not accumulate Python stack frames.
1287+
1288+
Raw HTTP fixtures model a peer closing every response after one checkpoint;
1289+
the final response must arrive without changing the interpreter recursion limit.
1290+
"""
1291+
connections = 1500
1292+
seen_cursors: list[str | None] = []
1293+
tokens: list[str] = []
1294+
1295+
async def on_token(token: str) -> None:
1296+
tokens.append(token)
1297+
1298+
def handler(request: httpx2.Request) -> httpx2.Response:
1299+
seen_cursors.append(request.headers.get("last-event-id"))
1300+
index = len(seen_cursors)
1301+
payload: dict[str, Any] = (
1302+
{"jsonrpc": "2.0", "id": "long-call", "result": {}}
1303+
if index == connections
1304+
else {
1305+
"jsonrpc": "2.0",
1306+
"method": "notifications/message",
1307+
"params": {"level": "info", "data": index},
1308+
}
1309+
)
1310+
return httpx2.Response(
1311+
200,
1312+
headers={"content-type": "text/event-stream"},
1313+
content=f"id: checkpoint-{index}\nretry: 0\ndata: {json.dumps(payload)}\n\n".encode(),
1314+
)
1315+
1316+
with anyio.fail_after(5):
1317+
async with (
1318+
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
1319+
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
1320+
):
1321+
await write.send(
1322+
SessionMessage(
1323+
JSONRPCRequest(jsonrpc="2.0", id="long-call", method="tools/list"),
1324+
metadata=ClientMessageMetadata(on_resumption_token_update=on_token),
1325+
)
1326+
)
1327+
for index in range(1, connections):
1328+
notification = await read.receive()
1329+
assert isinstance(notification, SessionMessage)
1330+
assert notification.message == JSONRPCNotification(
1331+
jsonrpc="2.0",
1332+
method="notifications/message",
1333+
params={"level": "info", "data": index},
1334+
)
1335+
response = await read.receive()
1336+
assert isinstance(response, SessionMessage)
1337+
assert response.message == JSONRPCResponse(jsonrpc="2.0", id="long-call", result={})
1338+
1339+
assert seen_cursors == [None, *(f"checkpoint-{index}" for index in range(1, connections))]
1340+
assert tokens == [f"checkpoint-{index}" for index in range(1, connections + 1)]

0 commit comments

Comments
 (0)