From 5b32b919e1d960db28788cee39cd81e798da07ba Mon Sep 17 00:00:00 2001 From: "Michiel W. Beijen" Date: Sat, 16 May 2026 16:12:44 +0200 Subject: [PATCH] Store elapsed time on stream wrapper to avoid reference cycles Instead of holding a reference back to the Response in BoundSyncStream/ BoundAsyncStream (which creates a reference cycle), store the elapsed timedelta on the stream itself after close. Response.elapsed reads it back from self.stream via duck typing, falling back to a directly-set _elapsed value for cases like mocking. This avoids creating reference cycles that can result in significant extra memory usage. It's an alternative approach to https://github.com/encode/httpx/pull/3733 and I prefer my approach, because it does not use references at all (no weakref). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/httpx2/httpx2/_client.py | 28 +++++++++++++-------------- src/httpx2/httpx2/_models.py | 3 +++ tests/httpx2/models/test_responses.py | 21 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/httpx2/httpx2/_client.py b/src/httpx2/httpx2/_client.py index f7535d33..e0ce82ed 100644 --- a/src/httpx2/httpx2/_client.py +++ b/src/httpx2/httpx2/_client.py @@ -132,43 +132,43 @@ class ClientState(enum.Enum): class BoundSyncStream(SyncByteStream): """ - A byte stream that is bound to a given response instance, and that - ensures the `response.elapsed` is set once the response is closed. + A byte stream that tracks elapsed time for a response. Once closed, the + elapsed time is available via the `elapsed` attribute, and the response + can read it back from `response.stream.elapsed`. """ - def __init__(self, stream: SyncByteStream, response: Response, start: float) -> None: + def __init__(self, stream: SyncByteStream, start: float) -> None: self._stream = stream - self._response = response self._start = start + self.elapsed: datetime.timedelta | None = None def __iter__(self) -> typing.Iterator[bytes]: for chunk in self._stream: yield chunk def close(self) -> None: - elapsed = time.perf_counter() - self._start - self._response.elapsed = datetime.timedelta(seconds=elapsed) + self.elapsed = datetime.timedelta(seconds=time.perf_counter() - self._start) self._stream.close() class BoundAsyncStream(AsyncByteStream): """ - An async byte stream that is bound to a given response instance, and that - ensures the `response.elapsed` is set once the response is closed. + An async byte stream that tracks elapsed time for a response. Once closed, + the elapsed time is available via the `elapsed` attribute, and the response + can read it back from `response.stream.elapsed`. """ - def __init__(self, stream: AsyncByteStream, response: Response, start: float) -> None: + def __init__(self, stream: AsyncByteStream, start: float) -> None: self._stream = stream - self._response = response self._start = start + self.elapsed: datetime.timedelta | None = None async def __aiter__(self) -> typing.AsyncIterator[bytes]: async for chunk in self._stream: yield chunk async def aclose(self) -> None: - elapsed = time.perf_counter() - self._start - self._response.elapsed = datetime.timedelta(seconds=elapsed) + self.elapsed = datetime.timedelta(seconds=time.perf_counter() - self._start) await self._stream.aclose() @@ -977,7 +977,7 @@ def _send_single_request(self, request: Request) -> Response: assert isinstance(response.stream, SyncByteStream) response.request = request - response.stream = BoundSyncStream(response.stream, response=response, start=start) + response.stream = BoundSyncStream(response.stream, start=start) self.cookies.extract_cookies(response) response.default_encoding = self._default_encoding @@ -1680,7 +1680,7 @@ async def _send_single_request(self, request: Request) -> Response: assert isinstance(response.stream, AsyncByteStream) response.request = request - response.stream = BoundAsyncStream(response.stream, response=response, start=start) + response.stream = BoundAsyncStream(response.stream, start=start) self.cookies.extract_cookies(response) response.default_encoding = self._default_encoding diff --git a/src/httpx2/httpx2/_models.py b/src/httpx2/httpx2/_models.py index e6aeabd6..834f0806 100644 --- a/src/httpx2/httpx2/_models.py +++ b/src/httpx2/httpx2/_models.py @@ -563,6 +563,9 @@ def elapsed(self) -> datetime.timedelta: cycle to complete. """ if not hasattr(self, "_elapsed"): + stream_elapsed: datetime.timedelta | None = getattr(self.stream, "elapsed", None) + if stream_elapsed is not None: + return stream_elapsed raise RuntimeError("'.elapsed' may only be accessed after the response has been read or closed.") return self._elapsed diff --git a/tests/httpx2/models/test_responses.py b/tests/httpx2/models/test_responses.py index 860d99c1..a6014609 100644 --- a/tests/httpx2/models/test_responses.py +++ b/tests/httpx2/models/test_responses.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime import json import pickle import typing @@ -771,6 +772,26 @@ async def test_elapsed_not_available_until_closed() -> None: response.elapsed # noqa: B018 +def test_elapsed_read_from_stream() -> None: + """Response.elapsed should read from stream.elapsed (the normal streaming path).""" + from httpx2._client import BoundSyncStream + + inner = httpx2.ByteStream(b"") + bound = BoundSyncStream(inner, start=0.0) + bound.elapsed = datetime.timedelta(seconds=1) + + response = httpx2.Response(200) + response.stream = bound + assert response.elapsed == datetime.timedelta(seconds=1) + + +def test_elapsed_set_directly() -> None: + """Response.elapsed can be set directly, e.g. in mocks or tests.""" + response = httpx2.Response(200) + response.elapsed = datetime.timedelta(seconds=1) + assert response.elapsed == datetime.timedelta(seconds=1) + + def test_unknown_status_code() -> None: response = httpx2.Response( 600,