-
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 #1706
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
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. | ||
|
Contributor
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. Confirming @chatgpt-codex-connector's P2 with measurements — and the mechanism turns out to be different from what both this comment and the PR description say. I reproduced the fixture verbatim (the On an idle VM the tests are not vacuous: the server was at 512–586 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 absorbed an entire 2 MiB body and an entire 8 MiB body before the consumer reached the quarter mark; on a 32 MiB body the server was 13 MiB ahead at the stall. So ~500 KiB is not a ceiling this 2 MiB body clears — the buffers here hold at least 8 MiB, four times the whole body. (The ~526 KiB you measured is presumably read-ahead against a consumer that has already stopped reading; before the stall the consumer is reading at full speed, which is what lets the server race ahead.) 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: 100/400 chunks at the stall in every run, both flavors, 9/9 green including 4 runs under the same load that produced the degenerate runs above; both fail under the wall-clock mutation; and the body drops from 2 MiB to 400 KiB. It needs the server helper to hand back a small progress object next to the URL — Non-blocking on correctness, but the whole point of the PR is coverage that can't pass vacuously, and this is the one test here that currently can. |
||
| SLOW_CONSUMER_CHUNKS = 2048 | ||
| SLOW_CONSUMER_STALL_AT = SLOW_CONSUMER_CHUNKS // 4 | ||
|
Comment on lines
+263
to
+264
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.
On systems where TCP autotuning and the transport buffers can hold this 2 MiB body, or where the server simply wins the race, the terminating chunk may already have been written before the consumer reaches the fixed 512 KiB threshold. Both positive tests would then sleep over an already-completed transfer and pass without exercising slow-consumer behavior; the separate sync deadline test cannot prove that either positive connection—especially the async one—was still active. Gate the pause on server-side progress/backpressure instead of assuming a fixed buffer capacity so these tests actually cover the intended path. AGENTS.md reference: AGENTS.md:L5-L5 Useful? React with 👍 / 👎. |
||
|
|
||
|
|
||
| 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 | ||
| # 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
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: the A/B counterpart exists only for sync, but the two flavors reach the deadline through different code — the adapter's async path re-applies the remaining deadline around each chunk read ( Smaller point in the same test:
Contributor
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 test is the sole guard on forwarding an explicit The surviving mutant is a one-liner, in both # stream_timeout = VolumeConnectionConfig._get_request_timeout(
# None, opts.get("request_timeout")
# )
stream_timeout = timeout # FILE_TIMEOUT-based, i.e. 3600 s
A deadline-value assertion closes it without any timing, covers both flavours (which also settles the sync-only concern on my sibling's thread below, since it asserts at the call boundary rather than through the adapter's two different enforcement paths), and runs in 0.5 s: @pytest.fixture
def recorded_stream_timeouts(monkeypatch):
seen = []
sync_stream, async_stream = httpx.Client.stream, httpx.AsyncClient.stream
def sync_spy(self, *args, **kwargs):
seen.append(kwargs.get("timeout"))
return sync_stream(self, *args, **kwargs)
def async_spy(self, *args, **kwargs):
seen.append(kwargs.get("timeout"))
return async_stream(self, *args, **kwargs)
monkeypatch.setattr(httpx.Client, "stream", sync_spy)
monkeypatch.setattr(httpx.AsyncClient, "stream", async_spy)
return seen
def test_sync_streamed_read_carries_no_deadline_by_default(recorded_stream_timeouts):
api_url = _start_volume_file_server([0.0])
volume = Volume(volume_id="v1", name="test", token="vol-token")
stream = volume.read_file("file.bin", format="stream", api_url=api_url)
assert b"".join(stream) == CHUNK
# A whole-request deadline here would cap total transfer time, which is
# what FILE_TIMEOUT does on the non-streamed path.
assert recorded_stream_timeouts == [None]
def test_sync_streamed_read_forwards_an_explicit_request_timeout(recorded_stream_timeouts):
api_url = _start_volume_file_server([0.0])
volume = Volume(volume_id="v1", name="test", token="vol-token")
stream = volume.read_file(
"file.bin", format="stream", request_timeout=12.0, api_url=api_url
)
assert b"".join(stream) == CHUNK
assert recorded_stream_timeouts == [12.0]I ran these plus their two |
||
| 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 | ||
| # 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.
Two small things about this file that came out of the mutation run.
On the widening: I checked what a truncated body actually surfaces as here, and it is deterministically
httpx.RemoteProtocolError("peer closed connection without sending complete message body (incomplete chunked read)"). The stated reason for relaxing the assertion — which error a truncation surfaces as is the transport's business — does not apply to this test as written, because_open_streambuilds a barehttpx.Client(), so the error class is httpx's own and not the shipped pyqwest transport's. The relaxation is therefore paying specificity for portability the test cannot have:httpx.HTTPErroris the base ofReadTimeout,ConnectTimeout,ConnectErrorandPoolTimeouttoo, so a regression that turned a truncation into a hang-then-timeout would still satisfy it. KeepingRemoteProtocolError(or asserting onhttpx.TransportErrorif a slightly wider net is wanted) costs nothing today.Separately, and not something this PR introduced:
test_sync_full_consume_releases_responseabove does not distinguish the reader's release from httpx's. NarrowingFileStreamReader.__next__'sexcept BaseExceptiontoexcept httpx.HTTPError— which removes theStopIteration→close()path entirely — leaves all 11 tests in this file green, becauseiter_bytes()closes the response itself when the stream ends. The error path is genuinely pinned (deletingself.close()from the handler failstest_sync_read_error_releases_response); the normal-end path is redundant with httpx and asserted by nobody. Worth a line in that test if the reader is meant to own the release regardless of what httpx does.