Skip to content

Commit b9ff993

Browse files
mishushakovclaude
andcommitted
feat(python-sdk): honor stream_idle_timeout per read in AsyncVolume.read_file
asyncio.wait_for around each read (response head and every chunk); explicit values run on the regular transport so values above the 60s transport bound aren't capped and 0 disables idle bounding. Sync keeps the parameter but ignores it — it cannot interrupt a blocking read into the Rust transport. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7c6d4e8 commit b9ff993

3 files changed

Lines changed: 98 additions & 22 deletions

File tree

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

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

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

@@ -518,10 +533,17 @@ async def stream_file() -> AsyncIterator[bytes]:
518533
)
519534
raise handle_api_exception(api_response, VolumeException)
520535

521-
async for chunk in response.aiter_bytes():
536+
chunks = response.aiter_bytes()
537+
while True:
538+
try:
539+
chunk = await read_bounded(chunks.__anext__())
540+
except StopAsyncIteration:
541+
break
522542
yield chunk
543+
finally:
544+
await stream_cm.__aexit__(None, None, None)
523545
# asyncio.TimeoutError is distinct from the builtin until 3.11,
524-
# and the adapter's whole-request deadline raises it.
546+
# and wait_for and the adapter's whole-request deadline raise it.
525547
except (TimeoutError, asyncio.TimeoutError) as e:
526548
raise httpx.ReadTimeout(str(e)) from e
527549

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -462,9 +462,11 @@ def read_file(
462462
463463
:param path: Path to the file
464464
:param format: Format of the file content—`text` by default
465-
:param stream_idle_timeout: Deprecated and ignored. A stalled streamed
466-
read is bounded by a transport-wide idle read timeout instead
467-
(60 seconds), which resets on every chunk.
465+
:param stream_idle_timeout: Ignored — the sync client cannot
466+
interrupt a blocking read. A stalled streamed read is bounded by
467+
a transport-wide idle read timeout instead (60 seconds), which
468+
resets on every chunk. (`AsyncVolume.read_file` honors this
469+
parameter.)
468470
:param opts: Connection options
469471
470472
: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)