Skip to content

Commit 76bcfc9

Browse files
fix(client): bound empty GET stream retries
1 parent a4f4ccd commit 76bcfc9

2 files changed

Lines changed: 30 additions & 2 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,9 @@ async def handle_get_stream(self, client: httpx2.AsyncClient, read_stream_writer
214214
event_source.response.raise_for_status()
215215
logger.debug("GET SSE connection established")
216216

217+
saw_event = False
217218
async for sse in event_source:
219+
saw_event = True
218220
# Track last event ID for reconnection
219221
if sse.id:
220222
last_event_id = sse.id
@@ -224,8 +226,9 @@ async def handle_get_stream(self, client: httpx2.AsyncClient, read_stream_writer
224226

225227
await self._handle_sse_event(sse, read_stream_writer)
226228

227-
# Stream ended normally (server closed) - reset attempt counter
228-
attempt = 0
229+
# A clean stream close is retryable only when the server sent an event.
230+
# An empty stream is a terminated endpoint, so count it against the retry budget.
231+
attempt = 0 if saw_event else attempt + 1
229232

230233
except Exception:
231234
logger.debug("GET stream error", exc_info=True)

tests/client/test_streamable_http.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,31 @@ async def test_exhausted_reconnection_attempts_resolve_the_request_with_an_error
736736
receive.close()
737737

738738

739+
@pytest.mark.anyio
740+
async def test_empty_get_stream_exhausts_reconnection_attempts(monkeypatch: pytest.MonkeyPatch) -> None:
741+
"""An empty, cleanly closed GET stream must not reset the reconnection budget."""
742+
requests: list[httpx2.Request] = []
743+
744+
def handler(request: httpx2.Request) -> httpx2.Response:
745+
requests.append(request)
746+
return httpx2.Response(200, headers={"content-type": "text/event-stream"}, content=b"")
747+
748+
async def no_sleep(_delay: float) -> None:
749+
pass
750+
751+
monkeypatch.setattr("mcp.client.streamable_http.anyio.sleep", no_sleep)
752+
transport = StreamableHTTPTransport("http://test/mcp")
753+
transport.session_id = "session-1"
754+
send, receive = create_context_streams[SessionMessage | Exception](0)
755+
756+
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
757+
await transport.handle_get_stream(http, send)
758+
759+
assert len(requests) == MAX_RECONNECTION_ATTEMPTS
760+
send.close()
761+
receive.close()
762+
763+
739764
@pytest.mark.anyio
740765
async def test_resolving_an_abandoned_request_after_the_reader_closed_is_contained() -> None:
741766
"""Teardown race: a stream dying after the reader closed resolves best-effort and must not crash."""

0 commit comments

Comments
 (0)