-
Notifications
You must be signed in to change notification settings - Fork 1k
test(python-sdk): restore the stream-reader coverage dropped with the httpcore tests #1710
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -169,6 +169,20 @@ async def get_transports(): | |
| CHUNK = b"x" * 1024 | ||
|
|
||
|
|
||
| def _read_request_head(conn) -> None: | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: this helper is byte-identical to the one added at Sharing is already set up: |
||
| """Read up to the end of the request head. | ||
|
|
||
| Accumulates across reads, since the head can arrive split across TCP | ||
| segments, and stops on a closed peer rather than spinning on empty reads. | ||
| """ | ||
| buffered = b"" | ||
| while b"\r\n\r\n" not in buffered: | ||
| received = conn.recv(65536) | ||
| if not received: | ||
| return | ||
| buffered += received | ||
|
|
||
|
|
||
| def _start_volume_file_server( | ||
| chunk_delays: List[float], ttfb_delay: float = 0.0 | ||
| ) -> str: | ||
|
|
@@ -183,8 +197,7 @@ def _start_volume_file_server( | |
| def serve(): | ||
| try: | ||
| conn, _ = sock.accept() | ||
| while b"\r\n\r\n" not in conn.recv(65536): | ||
| pass | ||
| _read_request_head(conn) | ||
| time.sleep(ttfb_delay) | ||
| conn.sendall( | ||
| b"HTTP/1.1 200 OK\r\n" | ||
|
|
@@ -244,6 +257,38 @@ def test_sync_stream_stall_raises_read_timeout(short_read_timeout): | |
| assert received == [CHUNK] | ||
|
|
||
|
|
||
| # A body far past the ~500 KiB the socket and reqwest buffers hold between | ||
| # them, so a stall a quarter of the way in provably lands mid-transfer with the | ||
| # server still pushing, rather than after the whole body was read ahead. | ||
| SLOW_CONSUMER_CHUNKS = 2048 | ||
|
mishushakov marked this conversation as resolved.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The finding worth acting on. These two tests are non-vacuous on an idle machine, but they degenerate under the load CI actually runs, and the mechanism that makes them work is not the one this comment describes. I reproduced the fixture verbatim — the On an idle VM the property holds: the server was at 512–572 of 2048 chunks with The capacity claim is off by more than an order of magnitude, in the direction that removes the headroom. With the GIL yield removed, the client stack absorbed the entire 2 MiB body and its terminating chunk before the consumer reached the quarter mark; on an 8 MiB body the server was ~5.8 MiB in at the stall, and on 32 MiB it was ~13.7 MiB in. So ~500 KiB is not a ceiling this 2 MiB body clears — the buffers here hold several MiB. (A "~526 KiB" figure measured against a consumer that has already stopped reading does not bound read-ahead during the phase where the consumer is reading flat out.) Under CI conditions the property does break. Verified alternative: pace the server rather than trying to out-size the buffers, and assert the property. The fixture already takes per-chunk delays, so SLOW_CONSUMER_CHUNKS = 400
SLOW_CONSUMER_PACE = 0.005 # every gap far under the idle bound
SLOW_CONSUMER_STALL_AT = SLOW_CONSUMER_CHUNKS // 4
api_url, server = _start_volume_file_server([SLOW_CONSUMER_PACE] * SLOW_CONSUMER_CHUNKS)
...
if not stalled and received >= len(CHUNK) * SLOW_CONSUMER_STALL_AT:
stalled = True
# The stall has to land while the server is still pushing, or the case
# degenerates into re-reading an already-buffered body.
assert not server.body_complete
time.sleep(short_read_timeout * 3)Measured: 99–100 of 400 chunks at the stall in 10/10 runs across both flavors, all under the same 8-busy-loop load that produced the degenerate run above; the body drops from 2 MiB to 400 KiB. It needs the helper to hand back a small progress object next to the URL — Non-blocking on correctness, but the point of the PR is coverage that cannot pass vacuously, and this is the one test here that currently can. |
||
| SLOW_CONSUMER_STALL_AT = SLOW_CONSUMER_CHUNKS // 4 | ||
|
|
||
|
|
||
| def test_sync_stream_survives_a_consumer_slower_than_read_timeout(short_read_timeout): | ||
| # The idle read timeout is wire-only: the server sends every chunk | ||
| # promptly and the consumer then pauses for far longer than the bound, | ||
| # which must not abort the stream (parity with the JS SDK's | ||
| # "wrapStreamWithConnectionCleanup does not abort a slow consumer"). | ||
| # This guards the bound staying an idle one — putting a wall-clock deadline | ||
| # back on the streaming path would fail here while every stall test above | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This claim is slightly off, and the correction is in the test's favour rather than against it. I mutated the streaming path so the default bound becomes a whole-transfer deadline ( The regression it uniquely catches is a narrower and more interesting one: an idle bound measured across consumer time rather than wire time. I simulated that by starting the idle timer before the yield instead of after, and exactly one test in the file fails — this one. The five pre-existing timing tests all pass, because none of them ever pauses between reads. So the comment would be both stronger and accurate as something like: "the bound must stay a wire-only one — a bound that also counted the consumer's own pauses would fail here and nowhere else in this file." |
||
| # kept passing. | ||
| api_url = _start_volume_file_server([0.0] * SLOW_CONSUMER_CHUNKS) | ||
| volume = Volume(volume_id="v1", name="test", token="vol-token") | ||
|
|
||
| stream = volume.read_file("file.bin", format="stream", api_url=api_url) | ||
| received = 0 | ||
| stalled = False | ||
| for chunk in stream: | ||
| received += len(chunk) | ||
| # A threshold rather than an exact count: the transport is free to | ||
| # coalesce or split the chunks it hands back. | ||
| if not stalled and received >= len(CHUNK) * SLOW_CONSUMER_STALL_AT: | ||
| stalled = True | ||
| time.sleep(short_read_timeout * 3) | ||
| assert stalled | ||
| assert received == len(CHUNK) * SLOW_CONSUMER_CHUNKS | ||
|
|
||
|
|
||
| def test_async_stream_survives_transfers_longer_than_read_timeout(short_read_timeout): | ||
| api_url = _start_volume_file_server([0.15] * 4) | ||
| volume = AsyncVolume(volume_id="v1", name="test", token="vol-token") | ||
|
|
@@ -255,6 +300,54 @@ async def run(): | |
| assert asyncio.run(run()) == CHUNK * 4 | ||
|
|
||
|
|
||
| def test_async_stream_survives_a_consumer_slower_than_read_timeout(short_read_timeout): | ||
| # Async counterpart of the sync slow-consumer case above, against the | ||
| # async streaming transport. | ||
| api_url = _start_volume_file_server([0.0] * SLOW_CONSUMER_CHUNKS) | ||
| volume = AsyncVolume(volume_id="v1", name="test", token="vol-token") | ||
|
|
||
| async def run(): | ||
| stream = await volume.read_file("file.bin", format="stream", api_url=api_url) | ||
| received = 0 | ||
| stalled = False | ||
| async for chunk in stream: | ||
| received += len(chunk) | ||
| if not stalled and received >= len(CHUNK) * SLOW_CONSUMER_STALL_AT: | ||
| stalled = True | ||
| await asyncio.sleep(short_read_timeout * 3) | ||
| return stalled, received | ||
|
|
||
| assert asyncio.run(run()) == (True, len(CHUNK) * SLOW_CONSUMER_CHUNKS) | ||
|
|
||
|
|
||
| def test_sync_stream_explicit_request_timeout_is_a_total_transfer_deadline( | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit, but a cheap one to close: this A/B counterpart exists only for sync, while the two flavors reach the deadline through different code — the pyqwest adapter's async path re-applies the remaining deadline around each chunk read ( I checked that the async twin would actually pass rather than just suggesting it: same fixture and body, Smaller point in the same test: |
||
| short_read_timeout, | ||
| ): | ||
| # The counterpart that makes the two tests above meaningful: same fixture, | ||
| # same body, same stall — the only difference is the explicit | ||
| # `request_timeout`, which is documented as a whole-transfer deadline and | ||
| # so *does* cut off a slow consumer. Without this A/B, a regression that | ||
| # turned the default idle bound into a wall-clock one would look | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the most precisely targeted test in the PR, and the A/B framing pays off: dropping the caller's explicit One caveat on the reasoning in the last sentence. "The tests above establish that a slow consumer never trips the idle bound" holds against the current implementation, but the two tests above and this one share the same fixture, body size and stall, so a change to |
||
| # indistinguishable from correct behavior. The stall is longer than the | ||
| # total deadline, and the tests above establish that a slow consumer never | ||
| # trips the idle bound, so the timeout here can only be the deadline. | ||
| api_url = _start_volume_file_server([0.0] * SLOW_CONSUMER_CHUNKS) | ||
| volume = Volume(volume_id="v1", name="test", token="vol-token") | ||
|
|
||
| stream = volume.read_file( | ||
| "file.bin", format="stream", request_timeout=1.0, api_url=api_url | ||
| ) | ||
| received = 0 | ||
| stalled = False | ||
| with pytest.raises(httpx.TimeoutException): | ||
| for chunk in stream: | ||
| received += len(chunk) | ||
| if not stalled and received >= len(CHUNK) * SLOW_CONSUMER_STALL_AT: | ||
| stalled = True | ||
| time.sleep(2.0) | ||
| assert stalled | ||
|
|
||
|
|
||
| def test_async_stream_stall_raises_read_timeout(short_read_timeout): | ||
| api_url = _start_volume_file_server([0.0, 5.0]) | ||
| volume = AsyncVolume(volume_id="v1", name="test", token="vol-token") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Low priority, and the stated reasoning is sound — this file drives the readers against a bare
httpx.Client(), so which concrete error a truncated body produces genuinely is not the reader's contract.Worth knowing what the widening costs, though:
httpx.ReadTimeoutandhttpx.TimeoutExceptionare both subclasses ofhttpx.HTTPError, so after this change the test no longer distinguishes "the peer truncated the body and we noticed promptly" from "we hung until some idle bound expired". Both close the response, so theis_closedassertion does not separate them either.The release behaviour itself is still well pinned — removing the
except BaseException: self.close()from__next__fails this test and only this test (the full-consume test survives it, since httpx closes on a clean end-of-stream regardless). If you want the promptness back without re-coupling to the transport's error taxonomy, asserting the elapsed time is well under the idle bound would do it in one line.