Skip to content

Commit 762dfb3

Browse files
committed
fix: Raise StreamableHTTPError on reconnection failure
1 parent 2713b53 commit 762dfb3

2 files changed

Lines changed: 42 additions & 5 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ async def handle_get_stream(self, client: httpx2.AsyncClient, read_stream_writer
200200
last_event_id: str | None = None
201201
retry_interval_ms: int | None = None
202202
attempt: int = 0
203+
last_exc: Exception | None = None
203204

204205
while attempt < MAX_RECONNECTION_ATTEMPTS: # pragma: no branch
205206
try:
@@ -227,13 +228,16 @@ async def handle_get_stream(self, client: httpx2.AsyncClient, read_stream_writer
227228
# Stream ended normally (server closed) - reset attempt counter
228229
attempt = 0
229230

230-
except Exception:
231+
except Exception as exc:
231232
logger.debug("GET stream error", exc_info=True)
232233
attempt += 1
234+
last_exc = exc
233235

234-
if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover
236+
if attempt >= MAX_RECONNECTION_ATTEMPTS:
235237
logger.debug(f"GET stream max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded")
236-
return
238+
raise StreamableHTTPError(
239+
f"Failed to connect to GET stream after {MAX_RECONNECTION_ATTEMPTS} attempts"
240+
) from last_exc
237241

238242
# Wait before reconnecting
239243
delay_ms = retry_interval_ms if retry_interval_ms is not None else DEFAULT_RECONNECTION_DELAY_MS

tests/shared/test_streamable_http.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from contextlib import asynccontextmanager
1313
from dataclasses import dataclass, field
1414
from typing import Any
15-
from unittest.mock import MagicMock
15+
from unittest.mock import AsyncMock, MagicMock, patch
1616
from urllib.parse import urlparse
1717

1818
import anyio
@@ -45,7 +45,11 @@
4545
from mcp import MCPError
4646
from mcp.client import ClientRequestContext
4747
from mcp.client.session import ClientSession
48-
from mcp.client.streamable_http import StreamableHTTPTransport, streamable_http_client
48+
from mcp.client.streamable_http import (
49+
StreamableHTTPError,
50+
StreamableHTTPTransport,
51+
streamable_http_client,
52+
)
4953
from mcp.server import Server, ServerRequestContext
5054
from mcp.server.streamable_http import (
5155
GET_STREAM_KEY,
@@ -2283,3 +2287,32 @@ async def asgi_receive() -> Message:
22832287
assert body_chunks[-1] == {"type": "http.response.body", "body": b"", "more_body": False}
22842288
assert "Error in standalone SSE writer" not in caplog.text
22852289
assert "Error in standalone SSE response" not in caplog.text
2290+
2291+
2292+
@pytest.mark.anyio
2293+
async def test_reconnect_failure_propagates_error() -> None:
2294+
"""Client should raise StreamableHTTPError when reconnection fails completely."""
2295+
transport = StreamableHTTPTransport(url="http://localhost:8000/mcp")
2296+
transport.session_id = "test-session"
2297+
client = AsyncMock(spec=httpx2.AsyncClient)
2298+
2299+
# Create a context-aware stream writer (matches StreamWriter type alias)
2300+
write_stream, read_stream = create_context_streams[SessionMessage | Exception](1)
2301+
2302+
# Mock client.sse to raise an exception
2303+
client.sse.side_effect = Exception("Connection refused")
2304+
2305+
# Patch anyio.sleep to avoid waiting
2306+
with patch("mcp.client.streamable_http.anyio.sleep", new_callable=AsyncMock) as mock_sleep:
2307+
with pytest.raises(StreamableHTTPError) as exc_info:
2308+
await transport.handle_get_stream(client, write_stream)
2309+
assert "Failed to connect to GET stream" in str(exc_info.value)
2310+
# Should have attempted MAX_RECONNECTION_ATTEMPTS times (default is 2)
2311+
assert client.sse.call_count == 2
2312+
# Should have slept between attempts (attempts - 1 times)
2313+
assert mock_sleep.call_count == 1
2314+
# Verify it slept with the default delay (1000ms / 1000.0 = 1.0s)
2315+
mock_sleep.assert_called_once_with(1.0)
2316+
2317+
await write_stream.aclose()
2318+
await read_stream.aclose()

0 commit comments

Comments
 (0)