Repository navigation
fix: capped responses keep elapsed, next_request and default_encoding; capped redirect and auth hops reuse their connection - #179
Merged
Conversation
…; capped redirect and auth hops reuse their connection
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
Both fixes apply only when
max_response_body_bytesis set, and to bothClientandAsyncClient.The rebuilt response keeps three attributes
With a cap, httpware reads the body under the cap and rebuilds the response with
httpx2.Response(content=...). That rebuild used to lose:elapsed, which raisedRuntimeError;next_request, so a 3xx returned withfollow_redirects=Falsehadnext_request = None;default_encoding, socafésent as latin-1 with no charset decoded ascaf�on a client built withdefault_encoding="latin-1".All three are now copied with public httpx2 API: the
default_encodingconstructor argument, thenext_requestattribute and theelapsedsetter. To getelapsed, the streaming response is closed once its body has been read in full, because httpx2 records elapsed time when the stream closes. Closing is idempotent, so the callers' ownclose()is unchanged.Redirect and auth hops reuse their connection
Under a cap, httpware follows redirects and runs the auth flow itself. It used to close each intermediate response (a redirect, a
DigestAuthchallenge) without reading the body. httpx2 cannot return a connection with an unread body to the pool, so every hop opened a new TCP connection. httpware now reads and discards each intermediate body before closing it. The cap still bounds that read. A hop whoseContent-Lengthis over the cap is not read at all, and a hop body that grows past the cap is abandoned once it does. Either way the connection is closed, as before, and the request carries on. Nothing is kept, soresponse.historyentries still have no content.ADR-0009
ADR-0009 said keeping
elapsedwould need httpx2 internals. That was wrong:Response.elapsedhas a public setter. The ADR now describes what the rebuilt response keeps and points to #170, which tracks counting bytes in a stream wrapper so the rebuild can go away. The decision itself (decoded bytes, one read path) is unchanged.docs/errors.mddescribes the new hop behaviour and what the rebuilt response keeps and loses.Behaviour changes for the 0.22.0 release notes
response.elapsed,response.next_requestandresponse.text(through the client'sdefault_encoding) now match what you get without a cap.num_bytes_downloadedreads0, because httpx2 has no public setter for it. TheContent-EncodingandTransfer-Encodingheaders are dropped andContent-Lengthis the decoded size, as before.Why not raise on an oversized hop body
Raising
ResponseTooLargeErrorfor an oversized hop body would match the final response. It would also break requests that work today: the existing tests pin that a 100 KB redirect or challenge body under a 1 KB cap does not fail the request. Abandoning the hop gives the same memory bound without the break, and costs one dropped connection only in that rare case.Test evidence
Each fix was test-first, in both worlds:
tests/test_client_body_cap.py:test_{sync,async}_response_keeps_{elapsed,next_request,default_encoding}_with_or_without_a_cap. Before the fix, the capped cases failed withRuntimeError: '.elapsed' may only be accessed after the response has been read or closed.,assert None is not None(next_request) andassert 'caf�' == 'café'.tests/test_client_body_cap_redirects.pyandtests/test_client_body_cap_auth.py:test_{sync,async}_reads_an_intermediate_redirect_body_to_the_end_with_or_without_a_capandtest_{sync,async}_reads_a_digest_challenge_body_to_the_end_with_or_without_a_capuse a hop body generator that records when it is read to the end. They failed before the fix withassert [] == [True]. The old tests asserting that no hop body is read under a cap now assert that it is read only up to the cap: 2 of 100 chunks, forsend()andstream(). A new parametrized test pins that a hop declaring aContent-Lengthover the cap is not read, except forHEAD.MockTransportcannot show connection reuse, so I checked it against a localhttp.serverwith keep-alive, counting accepted connections over 200 GETs through a 5-hop redirect chain:On loopback the capped sync GET went from 3.78 ms to 2.46 ms, about what the uncapped path takes (2.37 ms).
just lint,just lint-ciandjust test-cipass: 1219 tests, 100% coverage. The cap tests also pass on Python 3.11.Performance
The single-request capped path does about 0.4 µs more work: two attribute sets, one constructor argument, and a
close()that is a no-op on a fully read stream. Against roughly 60 µs per cappedget()onMockTransport, that is below the noise of the before/after timeit runs. Hops cost one read of the body to free the connection, which saves a TCP connect per hop.