refactor: connect-first stream lifecycle for sse and streamable_http (#2292)

Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
This commit is contained in:
Max Isbey 2026-03-16 23:30:20 +00:00 • committed by GitHub
commit 75a80b6f07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -58,22 +58,39 @@ def _assert_no_memory_stream_leak() -> Iterator[None]:
@pytest.mark.anyio
async def test_sse_client_closes_all_streams_on_connection_error(free_tcp_port: int) -> None:
"""sse_client must close all 4 stream ends when the connection fails.
"""sse_client creates streams only after the SSE connection succeeds, so a
ConnectError propagates directly with nothing to leak.
Before the fix, only read_stream_writer and write_stream were closed in
the finally block. read_stream and write_stream_reader were leaked.
Before the fix, streams were created before connecting and only 2 of 4 were
closed in the finally block.
"""
with _assert_no_memory_stream_leak():
# sse_client enters a task group BEFORE connecting, so anyio wraps the
# ConnectError from aconnect_sse in an ExceptionGroup.
with pytest.raises(Exception) as exc_info: # noqa: B017
with pytest.raises(httpx.ConnectError):
async with sse_client(f"http://127.0.0.1:{free_tcp_port}/sse"):
pytest.fail("should not reach here") # pragma: no cover
assert exc_info.group_contains(httpx.ConnectError)
# exc_info holds the traceback → holds frame locals → keeps leaked
# streams alive. Must drop it before gc.collect() can detect a leak.
del exc_info
@pytest.mark.anyio
async def test_sse_client_closes_all_streams_on_http_error() -> None:
"""sse_client creates streams only after raise_for_status() passes, so an
HTTPStatusError from a 4xx/5xx response propagates bare (not wrapped in an
ExceptionGroup) with nothing to leak — the task group is never entered.
"""
def return_403(request: httpx.Request) -> httpx.Response:
return httpx.Response(403)
def mock_factory(
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(return_403))
with _assert_no_memory_stream_leak():
with pytest.raises(httpx.HTTPStatusError):
async with sse_client("http://test/sse", httpx_client_factory=mock_factory):
pytest.fail("should not reach here") # pragma: no cover
@pytest.mark.anyio