Skip to content

Commit 676f863

Browse files
mishushakovclaude
andcommitted
feat(python-sdk): honor stream_idle_timeout per read in AsyncVolume.read_file
The async client bounds each read (response head and every chunk) with asyncio.wait_for when the caller passes an explicit stream_idle_timeout, mirroring the JS SDK's streamIdleTimeoutMs watchdog and the envd stream setup bound in #1558. Explicit values run on the regular transport so they aren't capped by the streaming transport's 60s idle bound, and 0 disables idle bounding entirely. The sync client keeps ignoring the parameter — it cannot interrupt a blocking read, so its bound stays on the streaming transport. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a051bbb commit 676f863

4 files changed

Lines changed: 106 additions & 28 deletions

File tree

.changeset/python-pyqwest-rest-transport.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ API is unchanged — only the transport underneath is swapped — so logging
1111
event hooks, per-request timeouts, and headers behave as before.
1212

1313
For streamed volume reads (`Volume.read_file(format="stream")`), a stalled
14-
stream is now bounded by a transport-wide idle read timeout of 60 seconds
15-
that resets on every chunk (still surfaced as `httpx.ReadTimeout`; matches
16-
the JS SDK's default stream idle timeout); the per-call
17-
`stream_idle_timeout` parameter is deprecated and ignored. Passing
18-
`request_timeout` to a streamed read (and to template uploads) now bounds
19-
the whole transfer rather than individual socket operations.
14+
stream is by default bounded by a transport-wide idle read timeout of
15+
60 seconds that resets on every chunk (still surfaced as
16+
`httpx.ReadTimeout`; matches the JS SDK's default stream idle timeout).
17+
`AsyncVolume.read_file` keeps honoring an explicit `stream_idle_timeout`
18+
per read (including `0` to disable); the sync client ignores it — it cannot
19+
interrupt a blocking read. Passing `request_timeout` to a streamed read
20+
(and to template uploads) now bounds the whole transfer rather than
21+
individual socket operations.
2022

2123
Because pyqwest transports are thread-safe and loop-independent (I/O runs on
2224
a Rust runtime), the API connection pool is now shared process-wide per

packages/python-sdk/e2b/volume/volume_async.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -466,9 +466,13 @@ async def read_file(
466466
467467
:param path: Path to the file
468468
:param format: Format of the file content—`text` by default
469-
:param stream_idle_timeout: Deprecated and ignored. A stalled streamed
470-
read is bounded by a transport-wide idle read timeout instead
471-
(60 seconds), which resets on every chunk.
469+
:param stream_idle_timeout: Idle timeout in **seconds** for a streamed
470+
read (`format="stream"`)—abort with `httpx.ReadTimeout` if the
471+
response head or the next chunk doesn't arrive within this
472+
window. Resets on every chunk, so it bounds a stalled stream
473+
without limiting total transfer time. Defaults to the
474+
transport-wide idle read timeout (60 seconds); pass `0` to
475+
disable.
472476
:param opts: Connection options
473477
474478
:return: File content as string, bytes, or async iterator of bytes
@@ -486,27 +490,38 @@ async def read_file(
486490
# whole-request deadline that would kill long downloads, so a
487491
# streamed read is sent with one only when the caller set
488492
# `request_timeout` explicitly (making it the total-transfer
489-
# deadline). A stalled stream is instead bounded by the
490-
# transport-wide idle read timeout (see `get_transport`), which
491-
# resets on every chunk without limiting total transfer time.
493+
# deadline).
492494
stream_timeout = VolumeConnectionConfig._get_request_timeout(
493495
None, opts.get("request_timeout")
494496
)
495-
# The streaming transport carries the idle read timeout; the
496-
# regular one must not (it would cut off slow uploads and
497-
# responses), so streamed reads get their own client.
498-
stream_client = get_volume_api_client(config, for_streaming=True)
497+
# By default a stalled stream is bounded by the streaming
498+
# transport's idle read timeout (see `get_transport`). An
499+
# explicit `stream_idle_timeout` is applied per read with
500+
# `wait_for` instead, on the regular transport — so values above
501+
# the transport bound aren't capped by it and `0` disables idle
502+
# bounding entirely. (The sync client can't interrupt a blocking
503+
# read, so only the async flavor honors the per-call value.)
504+
stream_client = get_volume_api_client(
505+
config, for_streaming=stream_idle_timeout is None
506+
)
507+
508+
async def read_bounded(awaitable):
509+
if not stream_idle_timeout:
510+
return await awaitable
511+
return await asyncio.wait_for(awaitable, stream_idle_timeout)
499512

500513
async def stream_file() -> AsyncIterator[bytes]:
501514
# pyqwest raises the builtin TimeoutError; keep the httpx
502515
# exception the streamed-read contract established.
503516
try:
504-
async with stream_client.get_async_httpx_client().stream(
517+
stream_cm = stream_client.get_async_httpx_client().stream(
505518
method="GET",
506519
url=f"/volumecontent/{self._volume_id}/file",
507520
params=params,
508521
timeout=stream_timeout,
509-
) as response:
522+
)
523+
response = await read_bounded(stream_cm.__aenter__())
524+
try:
510525
if response.status_code == 404:
511526
raise NotFoundException(f"Path {path} not found")
512527

@@ -519,10 +534,17 @@ async def stream_file() -> AsyncIterator[bytes]:
519534
)
520535
raise handle_api_exception(api_response, VolumeException)
521536

522-
async for chunk in response.aiter_bytes():
537+
chunks = response.aiter_bytes()
538+
while True:
539+
try:
540+
chunk = await read_bounded(chunks.__anext__())
541+
except StopAsyncIteration:
542+
break
523543
yield chunk
544+
finally:
545+
await stream_cm.__aexit__(None, None, None)
524546
# asyncio.TimeoutError is distinct from the builtin until 3.11,
525-
# and the adapter's whole-request deadline raises it.
547+
# and wait_for and the adapter's whole-request deadline raise it.
526548
except (TimeoutError, asyncio.TimeoutError) as e:
527549
raise httpx.ReadTimeout(str(e)) from e
528550

packages/python-sdk/e2b/volume/volume_sync.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -463,9 +463,11 @@ def read_file(
463463
464464
:param path: Path to the file
465465
:param format: Format of the file content—`text` by default
466-
:param stream_idle_timeout: Deprecated and ignored. A stalled streamed
467-
read is bounded by a transport-wide idle read timeout instead
468-
(60 seconds), which resets on every chunk.
466+
:param stream_idle_timeout: Ignored — the sync client cannot
467+
interrupt a blocking read. A stalled streamed read is bounded by
468+
a transport-wide idle read timeout instead (60 seconds), which
469+
resets on every chunk. (`AsyncVolume.read_file` honors this
470+
parameter.)
469471
:param opts: Connection options
470472
471473
:return: File content as string, bytes, or iterator of bytes

packages/python-sdk/tests/test_volume_client.py

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,9 @@ def short_read_timeout(monkeypatch):
184184
def test_sync_stream_survives_transfers_longer_than_read_timeout(short_read_timeout):
185185
# The transport read timeout is an idle bound that resets on every chunk:
186186
# a healthy stream whose total duration exceeds it must complete.
187-
# `stream_idle_timeout` is deprecated and ignored — a value shorter than
188-
# every chunk gap must not abort the stream.
187+
# `stream_idle_timeout` is ignored in the sync client (it cannot
188+
# interrupt a blocking read) — a value shorter than every chunk gap must
189+
# not abort the stream.
189190
api_url = _start_volume_file_server([0.15] * 4)
190191
volume = Volume(volume_id="v1", name="test", token="vol-token")
191192

@@ -214,9 +215,7 @@ def test_async_stream_survives_transfers_longer_than_read_timeout(short_read_tim
214215
volume = AsyncVolume(volume_id="v1", name="test", token="vol-token")
215216

216217
async def run():
217-
stream = await volume.read_file(
218-
"file.bin", format="stream", stream_idle_timeout=0.01, api_url=api_url
219-
)
218+
stream = await volume.read_file("file.bin", format="stream", api_url=api_url)
220219
return b"".join([chunk async for chunk in stream])
221220

222221
assert asyncio.run(run()) == CHUNK * 4
@@ -237,6 +236,59 @@ async def run():
237236
assert asyncio.run(run()) == [CHUNK]
238237

239238

239+
def test_async_explicit_stream_idle_timeout_aborts_stall():
240+
# An explicit stream_idle_timeout is honored per read with wait_for
241+
# (like the JS SDK's streamIdleTimeoutMs) — no transport rebuild needed.
242+
reset_volume_transports()
243+
api_url = _start_volume_file_server([0.0, 5.0])
244+
volume = AsyncVolume(volume_id="v1", name="test", token="vol-token")
245+
246+
async def run():
247+
stream = await volume.read_file(
248+
"file.bin", format="stream", stream_idle_timeout=0.3, api_url=api_url
249+
)
250+
received = [await stream.__anext__()]
251+
with pytest.raises(httpx.ReadTimeout):
252+
async for chunk in stream:
253+
received.append(chunk)
254+
return received
255+
256+
try:
257+
assert asyncio.run(run()) == [CHUNK]
258+
finally:
259+
reset_volume_transports()
260+
261+
262+
def test_async_explicit_stream_idle_timeout_above_transport_bound(short_read_timeout):
263+
# An explicit value larger than the transport's idle read timeout must
264+
# not be capped by it: explicit values run on the regular transport.
265+
api_url = _start_volume_file_server([short_read_timeout * 1.5] * 3)
266+
volume = AsyncVolume(volume_id="v1", name="test", token="vol-token")
267+
268+
async def run():
269+
stream = await volume.read_file(
270+
"file.bin", format="stream", stream_idle_timeout=5.0, api_url=api_url
271+
)
272+
return b"".join([chunk async for chunk in stream])
273+
274+
assert asyncio.run(run()) == CHUNK * 3
275+
276+
277+
def test_async_stream_idle_timeout_zero_disables_idle_bound(short_read_timeout):
278+
# `stream_idle_timeout=0` disables idle bounding entirely — a stall
279+
# longer than the transport's idle read timeout must not abort.
280+
api_url = _start_volume_file_server([0.0, short_read_timeout * 3])
281+
volume = AsyncVolume(volume_id="v1", name="test", token="vol-token")
282+
283+
async def run():
284+
stream = await volume.read_file(
285+
"file.bin", format="stream", stream_idle_timeout=0, api_url=api_url
286+
)
287+
return b"".join([chunk async for chunk in stream])
288+
289+
assert asyncio.run(run()) == CHUNK * 2
290+
291+
240292
def test_stream_transport_is_separate_from_regular_transport():
241293
# reqwest's read timer keeps running while a request body is sent and
242294
# while waiting for the response head, so the idle read timeout lives on

0 commit comments

Comments
 (0)