Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/httpx2/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## Unreleased

### Fixed

* Restore `AsyncIterator[bytes]` as the return annotation of `Response.aiter_bytes()` and
`Response.aiter_raw()`, so that they can be overridden with any async iterator.
([#1168](https://github.com/pydantic/httpx2/pull/1168))

## 2.12.0 (August 18th, 2026)

### Changed
Expand Down
16 changes: 12 additions & 4 deletions src/httpx2/httpx2/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -972,11 +972,13 @@ async def aread(self) -> bytes:
Read and return the response content.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When an AsyncByteStream returns a custom iterator with aclose(), aiter_raw() skips that cleanup because AsyncGenerator is not a structural check for custom iterators. Detect and await an exposed aclose() method instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/httpx2/httpx2/_models.py, line 1059:

<comment>When an `AsyncByteStream` returns a custom iterator with `aclose()`, `aiter_raw()` skips that cleanup because `AsyncGenerator` is not a structural check for custom iterators. Detect and await an exposed `aclose()` method instead.</comment>

<file context>
@@ -1066,7 +1056,7 @@ async def aiter_raw(self, chunk_size: int | None = None) -> typing.AsyncIterator
                 yield chunk
         finally:
-            if isinstance(stream, _AsyncClosable):
+            if isinstance(stream, AsyncGenerator):
                 await stream.aclose()
             await self.aclose()
</file context>

"""
if not hasattr(self, "_content"):
async with contextlib.aclosing(self.aiter_bytes()) as parts:
parts = self.aiter_bytes()
closing = contextlib.aclosing(parts) if isinstance(parts, AsyncGenerator) else contextlib.nullcontext()
async with closing:
self._content = b"".join([part async for part in parts])
return self._content

async def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncGenerator[bytes, None]:
async def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]:
"""
A byte-iterator over the decoded response content.
This allows us to handle gzip, deflate, brotli, and zstd encoded responses.
Expand All @@ -989,7 +991,13 @@ async def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncGenera
decoder = self._get_content_decoder()
chunker = ByteChunker(chunk_size=chunk_size)
with request_context(request=self._request):
async with contextlib.aclosing(self.aiter_raw()) as raw_stream:
raw_stream = self.aiter_raw()
closing = (
contextlib.aclosing(raw_stream)
if isinstance(raw_stream, AsyncGenerator)
else contextlib.nullcontext()
)
async with closing:
async for raw_bytes in raw_stream:
for decoded in decoder.decode(raw_bytes):
for chunk in chunker.decode(decoded):
Expand Down Expand Up @@ -1028,7 +1036,7 @@ async def aiter_lines(self) -> typing.AsyncIterator[str]:
for line in decoder.flush():
yield line

async def aiter_raw(self, chunk_size: int | None = None) -> typing.AsyncGenerator[bytes, None]:
async def aiter_raw(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]:
"""
A byte-iterator over the raw response content.
"""
Expand Down
34 changes: 34 additions & 0 deletions tests/httpx2/models/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ async def async_streaming_body() -> typing.AsyncIterator[bytes]:
yield b"world!"


class PlainAsyncIterator:
def __init__(self, chunks: list[bytes]) -> None:
self._chunks = iter(chunks)

def __aiter__(self) -> PlainAsyncIterator:
return self

async def __anext__(self) -> bytes:
try:
return next(self._chunks)
except StopIteration:
raise StopAsyncIteration from None


def autodetect(content: bytes) -> str | None:
return chardet.detect(content).get("encoding")

Expand Down Expand Up @@ -493,6 +507,26 @@ async def test_aiter_raw_with_chunksize() -> None:
assert parts == [b"Hello, world!"]


@pytest.mark.anyio
async def test_aiter_bytes_override() -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: These rewritten tests only exercise async-generator overrides, so they pass without exercising the two behaviors this PR claims to restore/preserve: subclass overrides returning plain async iterators without aclose(), and custom iterators whose aclose() gets called on cleanup. The previous PlainAsyncIterator and ClosableAsyncIterator regression tests for those paths were removed here. At the current implementation, aread() and aiter_bytes() wrap the aiter_bytes()/aiter_raw() results in contextlib.aclosing(...), which unconditionally awaits .aclose() and raises AttributeError on a plain async iterator (verified). Keep a regression test that overrides with a plain async iterator lacking aclose(), and one asserting a custom iterator's aclose() is invoked.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/httpx2/models/test_responses.py, line 497:

<comment>These rewritten tests only exercise async-generator overrides, so they pass without exercising the two behaviors this PR claims to restore/preserve: subclass overrides returning plain async iterators without `aclose()`, and custom iterators whose `aclose()` gets called on cleanup. The previous `PlainAsyncIterator` and `ClosableAsyncIterator` regression tests for those paths were removed here. At the current implementation, `aread()` and `aiter_bytes()` wrap the `aiter_bytes()`/`aiter_raw()` results in `contextlib.aclosing(...)`, which unconditionally awaits `.aclose()` and raises AttributeError on a plain async iterator (verified). Keep a regression test that overrides with a plain async iterator lacking `aclose()`, and one asserting a custom iterator's `aclose()` is invoked.</comment>

<file context>
@@ -523,49 +494,25 @@ async def test_aiter_raw_with_chunksize() -> None:
 
 @pytest.mark.anyio
-async def test_aiter_bytes_override_with_plain_async_iterator() -> None:
+async def test_aiter_bytes_override() -> None:
     class CustomResponse(httpx2.Response):
-        def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]:
</file context>

class CustomResponse(httpx2.Response):
def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]:
return PlainAsyncIterator([b"Hello, ", b"world!"])

response = CustomResponse(200, content=async_streaming_body())
assert await response.aread() == b"Hello, world!"


@pytest.mark.anyio
async def test_aiter_raw_override() -> None:
class CustomResponse(httpx2.Response):
def aiter_raw(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]:
return PlainAsyncIterator([b"Hello, ", b"world!"])

response = CustomResponse(200, content=async_streaming_body())
assert await response.aread() == b"Hello, world!"


@pytest.mark.anyio
async def test_aiter_raw_on_sync() -> None:
response = httpx2.Response(
Expand Down
Loading