Skip to content

perf: count capped bytes on the stream for unencoded bodies - #192

Merged
lesnik512 merged 2 commits into
mainfrom
perf/body-cap-counting-stream
Oct 17, 2026
Merged

lesnik512 merged 2 commits into
mainfrom
perf/body-cap-counting-stream

Conversation

@lesnik512

@lesnik512 lesnik512 commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

For a capped response with no Content-Encoding, the decoded bytes are the wire bytes. _read_capped / _read_capped_async now wrap response.stream in _CappedSyncStream / _CappedAsyncStream, which count bytes and raise ResponseTooLargeError(reason="streamed") once the count passes the cap, and let httpx2 read the response with its own read() / aread(). The capped response is the object httpx2 built, so nothing is copied. Bodies with any Content-Encoding (including identity) keep the rebuild path, as ADR-0009 requires.

Everything stays inside _internal/body_cap.py; client.py is unchanged. Every caller (the terminal, the auth requires_response_body path, the stream() error pre-read) goes through the two functions.

How it uses httpx2

Public API only: Response.stream, httpx2.SyncByteStream and httpx2.AsyncByteStream. I read httpx2 2.13.1 to confirm the details:

  • On httpx2 2.13.1, iter_raw / aiter_raw close the response in a finally. On the 2.0.0 floor they close it only when the body is read to the end, so an error mid-read leaves it open. The fast path therefore closes the response itself when read() / aread() raises, and a cap trip closes the response and the wrapped transport stream on every supported httpx2. close() / aclose() are idempotent in both versions, so the callers' own finally closes still work. The new tests check this with a stream that records its close.
  • On 2.13.1, Response.elapsed on a streamed response reads getattr(self.stream, "elapsed", None) from BoundSyncStream / BoundAsyncStream, so the wrappers forward elapsed. On 2.0.0, BoundSyncStream.close() sets response.elapsed itself, and the wrapper's close() delegates to it. The elapsed and num_bytes_downloaded tests pass on both.
  • The async wrapper closes the inner async generator in a finally, the same way BoundAsyncStream does.

The declared Content-Length check still runs first and raises reason="declared" before any byte is read. The error's status_code, limit, content_length and reason are unchanged.

Behaviour changes (unencoded bodies, cap set)

  • num_bytes_downloaded is the real byte count, the same as without a cap. It used to be 0.
  • Headers are the ones httpx2 built. A chunked response keeps Transfer-Encoding: chunked; it used to be dropped and a Content-Length added. Content-Length, when sent, is unchanged because it already equals the body size.
  • extensions keeps network_stream, as an uncapped response does. The rebuild dropped it.

Encoded bodies behave as before. docs/errors.md and ADR-0009 (updated in place) describe both paths.

Tests

Written first and failing before the change:

  • an unencoded body returns the same response object, closed, with num_bytes_downloaded set (sync and async)
  • an encoded body still returns a rebuilt response with num_bytes_downloaded == 0
  • an unencoded body over the cap mid-stream raises reason="streamed" and closes the response and its stream
  • client level: num_bytes_downloaded matches with and without a cap

just lint-ci and just test-ci pass (1352 tests, 100% coverage) after rebasing onto 660146e. The full suite also passes on a local floors install (httpx2 2.0.0, Python 3.14) built the way the floors job builds it.

Benchmark

Capped get() (max_response_body_bytes=1_000_000) against a MockTransport with a ~1 KB JSON body, best of 7 x 5000 calls, before and after runs interleaved in one session, two rounds:

case before (µs) after (µs)
sync, unencoded 62.0 / 67.2 48.1 / 54.8
async, unencoded 67.9 / 68.8 51.0 / 62.7
sync, gzip 74.6 / 75.1 71.0 / 72.5
async, gzip 74.9 / 81.5 75.9 / 76.0

The machine was under load, so absolute numbers run high, but earlier rounds under heavier load showed the same ratio (sync unencoded 158-183 µs before, 122-128 µs after). The gzip path is unchanged within noise.

Closes #170

For a response with no Content-Encoding, the body cap now wraps
response.stream in a stream that counts bytes and raises
ResponseTooLargeError once the count passes the cap, and httpx2 reads the
response with its own read()/aread(). The capped response is the object
httpx2 built, so its headers, extensions and num_bytes_downloaded match an
uncapped one. Encoded bodies keep the rebuild path.

Closes #170
httpx2 2.0.0 closes a response only when its body is read to the end,
so an over-cap read left it open on the floor version.
@lesnik512
lesnik512 merged commit a7cfa3a into main Oct 17, 2026
13 checks passed
@lesnik512
lesnik512 deleted the perf/body-cap-counting-stream branch October 17, 2026 11:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Body cap: count bytes on the stream instead of rebuilding the response for unencoded bodies

1 participant