From 1ecd77cbfd8e7df66cf0de955d261cb81ab95fad Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 16 Jul 2026 17:26:28 +0200 Subject: [PATCH 1/3] Attach the request to SSE errors via request_context Wrap the SSE iteration in request_context so any SSEError raised while consuming the stream carries the originating request, matching how the rest of the client attaches request context to errors. The content-type check no longer needs to pass request explicitly. --- src/httpx2/httpx2/_sse.py | 45 +++++++++++++++++++-------------------- tests/httpx2/test_sse.py | 4 +++- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/src/httpx2/httpx2/_sse.py b/src/httpx2/httpx2/_sse.py index f04c4703..c92a6ea8 100644 --- a/src/httpx2/httpx2/_sse.py +++ b/src/httpx2/httpx2/_sse.py @@ -10,7 +10,7 @@ from collections.abc import AsyncIterator, Iterator from dataclasses import dataclass -from ._exceptions import TransportError +from ._exceptions import TransportError, request_context from ._models import Response __all__ = ["EventSource", "SSEError", "ServerSentEvent"] @@ -124,35 +124,34 @@ def response(self) -> Response: def _check_content_type(self) -> None: content_type, _, _ = self._response.headers.get("content-type", "").partition(";") if content_type.strip().lower() != "text/event-stream": - raise SSEError( - f"Expected response with content type 'text/event-stream', got {content_type.strip()!r}.", - request=self._response.request, - ) + raise SSEError(f"Expected response with content type 'text/event-stream', got {content_type.strip()!r}.") def __iter__(self) -> Iterator[ServerSentEvent]: - self._check_content_type() - decoder = _SSEDecoder() - lines = _SSELineDecoder() - for chunk in self._response.iter_text(): - for line in lines.decode(chunk): + with request_context(request=self._response.request): + self._check_content_type() + decoder = _SSEDecoder() + lines = _SSELineDecoder() + for chunk in self._response.iter_text(): + for line in lines.decode(chunk): + sse = decoder.decode(line) + if sse is not None: + yield sse + for line in lines.flush(): sse = decoder.decode(line) if sse is not None: yield sse - for line in lines.flush(): - sse = decoder.decode(line) - if sse is not None: - yield sse async def __aiter__(self) -> AsyncIterator[ServerSentEvent]: - self._check_content_type() - decoder = _SSEDecoder() - lines = _SSELineDecoder() - async for chunk in self._response.aiter_text(): - for line in lines.decode(chunk): + with request_context(request=self._response.request): + self._check_content_type() + decoder = _SSEDecoder() + lines = _SSELineDecoder() + async for chunk in self._response.aiter_text(): + for line in lines.decode(chunk): + sse = decoder.decode(line) + if sse is not None: + yield sse + for line in lines.flush(): sse = decoder.decode(line) if sse is not None: yield sse - for line in lines.flush(): - sse = decoder.decode(line) - if sse is not None: - yield sse diff --git a/tests/httpx2/test_sse.py b/tests/httpx2/test_sse.py index ae32fe2f..cc893934 100644 --- a/tests/httpx2/test_sse.py +++ b/tests/httpx2/test_sse.py @@ -153,9 +153,11 @@ def handler(request: httpx2.Request) -> httpx2.Response: with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: with client.sse("http://testserver/sse") as source: - with pytest.raises(httpx2.SSEError, match="text/event-stream"): + with pytest.raises(httpx2.SSEError, match="text/event-stream") as exc_info: list(source) + assert exc_info.value.request.url == "http://testserver/sse" + @pytest.mark.anyio async def test_content_type_mismatch_raises_async() -> None: From 3862af1e2d5308d1dbc3d6868b9b2722add711c2 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 16 Jul 2026 17:46:13 +0200 Subject: [PATCH 2/3] Use the stored request so EventSource works without one Read the nullable _request attribute rather than the Response.request property, which raises when no request is attached. request_context already accepts None. Also assert the request on the async content-type test and cover a requestless EventSource. --- src/httpx2/httpx2/_sse.py | 4 ++-- tests/httpx2/test_sse.py | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/httpx2/httpx2/_sse.py b/src/httpx2/httpx2/_sse.py index c92a6ea8..f420a22a 100644 --- a/src/httpx2/httpx2/_sse.py +++ b/src/httpx2/httpx2/_sse.py @@ -127,7 +127,7 @@ def _check_content_type(self) -> None: raise SSEError(f"Expected response with content type 'text/event-stream', got {content_type.strip()!r}.") def __iter__(self) -> Iterator[ServerSentEvent]: - with request_context(request=self._response.request): + with request_context(request=self._response._request): self._check_content_type() decoder = _SSEDecoder() lines = _SSELineDecoder() @@ -142,7 +142,7 @@ def __iter__(self) -> Iterator[ServerSentEvent]: yield sse async def __aiter__(self) -> AsyncIterator[ServerSentEvent]: - with request_context(request=self._response.request): + with request_context(request=self._response._request): self._check_content_type() decoder = _SSEDecoder() lines = _SSELineDecoder() diff --git a/tests/httpx2/test_sse.py b/tests/httpx2/test_sse.py index cc893934..41791eb0 100644 --- a/tests/httpx2/test_sse.py +++ b/tests/httpx2/test_sse.py @@ -166,9 +166,21 @@ def handler(request: httpx2.Request) -> httpx2.Response: async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client: async with client.sse("http://testserver/sse") as source: - with pytest.raises(httpx2.SSEError, match="text/event-stream"): + with pytest.raises(httpx2.SSEError, match="text/event-stream") as exc_info: [event async for event in source] + assert exc_info.value.request.url == "http://testserver/sse" + + +def test_event_source_without_request() -> None: + response = httpx2.Response( + 200, content=b"data: hi\n\n", headers={"Content-Type": "text/event-stream"} + ) + + (event,) = list(httpx2.EventSource(response)) + + assert event.data == "hi" + def test_sets_sse_headers() -> None: captured: dict[str, str] = {} From ec0ec4a7e4b93b3902dcfeca83447d34d5f6d8ec Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 16 Jul 2026 17:51:01 +0200 Subject: [PATCH 3/3] Format the requestless EventSource test --- tests/httpx2/test_sse.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/httpx2/test_sse.py b/tests/httpx2/test_sse.py index 41791eb0..51a11c42 100644 --- a/tests/httpx2/test_sse.py +++ b/tests/httpx2/test_sse.py @@ -173,9 +173,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: def test_event_source_without_request() -> None: - response = httpx2.Response( - 200, content=b"data: hi\n\n", headers={"Content-Type": "text/event-stream"} - ) + response = httpx2.Response(200, content=b"data: hi\n\n", headers={"Content-Type": "text/event-stream"}) (event,) = list(httpx2.EventSource(response))