Skip to content

Commit a051bbb

Browse files
mishushakovclaude
andcommitted
feat(python-sdk): bound volume streams with a 60s idle read timeout on a dedicated transport
reqwest's read_timeout keeps ticking while a request body is sent and while waiting for the response head (verified against a local server), so a shared 60s read_timeout would cut off write_file uploads and slow unary responses. Streamed downloads therefore get their own transport carrying read_timeout=READ_TIMEOUT (60s, matching the JS SDK's default stream idle timeout), while uploads and unary calls stay on a transport without one, bounded by their whole-request deadlines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7bb223c commit a051bbb

7 files changed

Lines changed: 131 additions & 47 deletions

File tree

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ 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 that resets on
15-
every chunk (still surfaced as `httpx.ReadTimeout`); the per-call
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
1617
`stream_idle_timeout` parameter is deprecated and ignored. Passing
1718
`request_timeout` to a streamed read (and to template uploads) now bounds
1819
the whole transfer rather than individual socket operations.

packages/python-sdk/e2b/volume/client_async/__init__.py

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@
1515
from e2b.api.metadata import default_headers
1616
from e2b.exceptions import AuthenticationException
1717
from e2b.volume.client.client import AuthenticatedClient as AsyncVolumeApiClient
18-
from e2b.volume.connection_config import FILE_TIMEOUT, VolumeConnectionConfig
18+
from e2b.volume.connection_config import READ_TIMEOUT, VolumeConnectionConfig
1919

2020

21-
def get_api_client(config: VolumeConnectionConfig, **kwargs) -> AsyncVolumeApiClient:
21+
def get_api_client(
22+
config: VolumeConnectionConfig, *, for_streaming: bool = False, **kwargs
23+
) -> AsyncVolumeApiClient:
2224
if config.access_token is None:
2325
raise AuthenticationException(
2426
"Volume token is required for volume content operations. "
@@ -45,33 +47,41 @@ def get_api_client(config: VolumeConnectionConfig, **kwargs) -> AsyncVolumeApiCl
4547
httpx_args={
4648
# The proxy lives in the cached transport; passing `proxy` here too
4749
# would mount a fresh, never-closed proxy transport per client.
48-
"transport": get_transport(config),
50+
"transport": get_transport(config, for_streaming=for_streaming),
4951
"event_hooks": make_async_logging_event_hooks(config.logger),
5052
},
5153
**kwargs,
5254
)
5355

5456

5557
_transport_lock = threading.Lock()
56-
# One transport (= one connection pool) per proxy; None is the direct pool.
57-
# pyqwest's I/O runs on its own Rust runtime, so unlike the httpx transport
58-
# this replaced, the transport is not bound to an event loop and the cache
59-
# is process-global rather than per-loop.
60-
_transports: Dict[Optional[str], AsyncApiPyqwestTransport] = {}
58+
# One transport (= one connection pool) per (proxy, streaming) pair; None is
59+
# the direct pool. pyqwest's I/O runs on its own Rust runtime, so unlike the
60+
# httpx transport this replaced, the transport is not bound to an event loop
61+
# and the cache is process-global rather than per-loop.
62+
_transports: Dict[tuple[Optional[str], bool], AsyncApiPyqwestTransport] = {}
6163

6264

63-
def get_transport(config: VolumeConnectionConfig) -> AsyncApiPyqwestTransport:
64-
"""The shared pyqwest-backed httpx transport for volume content API calls.
65+
def get_transport(
66+
config: VolumeConnectionConfig, *, for_streaming: bool = False
67+
) -> AsyncApiPyqwestTransport:
68+
"""The shared pyqwest-backed httpx transports for volume content API calls.
6569
66-
``read_timeout`` is the idle bound on every read: it resets after each
67-
successful read, so it caps how long a streamed download may stall
68-
without limiting total transfer time. It is fixed per transport — the
69-
adapter's per-request timeouts are whole-request deadlines, and the sync
70-
adapter does not bound body reads at all.
70+
The streaming transport carries ``read_timeout``, the idle bound on every
71+
read: it resets after each successful read, so it caps how long a
72+
streamed download may stall without limiting total transfer time. It is
73+
fixed per transport — the adapter's per-request timeouts are
74+
whole-request deadlines, and the sync adapter does not bound body reads
75+
at all. Only streamed downloads use it: reqwest's read timer keeps
76+
running while a request body is sent and while waiting for the response
77+
head, so on the regular transport it would cut off uploads and slow
78+
unary responses longer than the idle bound (those stay bounded by their
79+
whole-request deadlines instead).
7180
"""
7281
proxy_url = proxy_to_url(config.proxy)
82+
key = (proxy_url, for_streaming)
7383
with _transport_lock:
74-
transport = _transports.get(proxy_url)
84+
transport = _transports.get(key)
7585
if transport is None:
7686
transport = AsyncApiPyqwestTransport(
7787
ConnectionRetryTransport(
@@ -80,10 +90,10 @@ def get_transport(config: VolumeConnectionConfig) -> AsyncApiPyqwestTransport:
8090
proxy=proxy_url,
8191
pool_idle_timeout=pool_idle_timeout,
8292
pool_max_idle_per_host=pool_max_idle_per_host,
83-
read_timeout=FILE_TIMEOUT,
93+
read_timeout=READ_TIMEOUT if for_streaming else None,
8494
),
8595
max_retries=connection_retries,
8696
)
8797
)
88-
_transports[proxy_url] = transport
98+
_transports[key] = transport
8999
return transport

packages/python-sdk/e2b/volume/client_sync/__init__.py

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@
1515
from e2b.api.metadata import default_headers
1616
from e2b.exceptions import AuthenticationException
1717
from e2b.volume.client.client import AuthenticatedClient as VolumeApiClient
18-
from e2b.volume.connection_config import FILE_TIMEOUT, VolumeConnectionConfig
18+
from e2b.volume.connection_config import READ_TIMEOUT, VolumeConnectionConfig
1919

2020

21-
def get_api_client(config: VolumeConnectionConfig, **kwargs) -> VolumeApiClient:
21+
def get_api_client(
22+
config: VolumeConnectionConfig, *, for_streaming: bool = False, **kwargs
23+
) -> VolumeApiClient:
2224
if config.access_token is None:
2325
raise AuthenticationException(
2426
"Volume token is required for volume content operations. "
@@ -45,32 +47,40 @@ def get_api_client(config: VolumeConnectionConfig, **kwargs) -> VolumeApiClient:
4547
httpx_args={
4648
# The proxy lives in the cached transport; passing `proxy` here too
4749
# would mount a fresh, never-closed proxy transport per client.
48-
"transport": get_transport(config),
50+
"transport": get_transport(config, for_streaming=for_streaming),
4951
"event_hooks": make_logging_event_hooks(config.logger),
5052
},
5153
**kwargs,
5254
)
5355

5456

5557
_transport_lock = threading.Lock()
56-
# One transport (= one connection pool) per proxy; None is the direct pool.
57-
# pyqwest transports are thread-safe, so unlike the httpx transport this
58-
# replaced, the cache is process-global rather than per-thread.
59-
_transports: Dict[Optional[str], ApiPyqwestTransport] = {}
58+
# One transport (= one connection pool) per (proxy, streaming) pair; None is
59+
# the direct pool. pyqwest transports are thread-safe, so unlike the httpx
60+
# transport this replaced, the cache is process-global rather than per-thread.
61+
_transports: Dict[tuple[Optional[str], bool], ApiPyqwestTransport] = {}
6062

6163

62-
def get_transport(config: VolumeConnectionConfig) -> ApiPyqwestTransport:
63-
"""The shared pyqwest-backed httpx transport for volume content API calls.
64+
def get_transport(
65+
config: VolumeConnectionConfig, *, for_streaming: bool = False
66+
) -> ApiPyqwestTransport:
67+
"""The shared pyqwest-backed httpx transports for volume content API calls.
6468
65-
``read_timeout`` is the idle bound on every read: it resets after each
66-
successful read, so it caps how long a streamed download may stall
67-
without limiting total transfer time. It is fixed per transport — the
68-
adapter's per-request timeouts are whole-request deadlines, and the sync
69-
adapter does not bound body reads at all.
69+
The streaming transport carries ``read_timeout``, the idle bound on every
70+
read: it resets after each successful read, so it caps how long a
71+
streamed download may stall without limiting total transfer time. It is
72+
fixed per transport — the adapter's per-request timeouts are
73+
whole-request deadlines, and the sync adapter does not bound body reads
74+
at all. Only streamed downloads use it: reqwest's read timer keeps
75+
running while a request body is sent and while waiting for the response
76+
head, so on the regular transport it would cut off uploads and slow
77+
unary responses longer than the idle bound (those stay bounded by their
78+
whole-request deadlines instead).
7079
"""
7180
proxy_url = proxy_to_url(config.proxy)
81+
key = (proxy_url, for_streaming)
7282
with _transport_lock:
73-
transport = _transports.get(proxy_url)
83+
transport = _transports.get(key)
7484
if transport is None:
7585
transport = ApiPyqwestTransport(
7686
ConnectionRetryTransport(
@@ -79,10 +89,10 @@ def get_transport(config: VolumeConnectionConfig) -> ApiPyqwestTransport:
7989
proxy=proxy_url,
8090
pool_idle_timeout=pool_idle_timeout,
8191
pool_max_idle_per_host=pool_max_idle_per_host,
82-
read_timeout=FILE_TIMEOUT,
92+
read_timeout=READ_TIMEOUT if for_streaming else None,
8393
),
8494
max_retries=connection_retries,
8595
)
8696
)
87-
_transports[proxy_url] = transport
97+
_transports[key] = transport
8898
return transport

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@
1515
# bounds each chunk by the request timeout and leaves the total to the server.)
1616
FILE_TIMEOUT: float = 3600.0 # 1 hour
1717

18+
# Idle bound for every read on the volume content transports: the transfer is
19+
# aborted when no bytes at all arrive for this long. It resets on each chunk,
20+
# so it never limits total transfer time — only a fully stalled connection.
21+
# Matches the JS SDK's default stream idle timeout (REQUEST_TIMEOUT_MS).
22+
READ_TIMEOUT: float = 60.0 # 60 seconds
23+
1824

1925
class VolumeApiParams(TypedDict, total=False):
2026
"""

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -467,8 +467,8 @@ async def read_file(
467467
:param path: Path to the file
468468
:param format: Format of the file content—`text` by default
469469
:param stream_idle_timeout: Deprecated and ignored. A stalled streamed
470-
read is bounded by a transport-wide idle read timeout instead,
471-
which resets on every chunk.
470+
read is bounded by a transport-wide idle read timeout instead
471+
(60 seconds), which resets on every chunk.
472472
:param opts: Connection options
473473
474474
:return: File content as string, bytes, or async iterator of bytes
@@ -492,12 +492,16 @@ async def read_file(
492492
stream_timeout = VolumeConnectionConfig._get_request_timeout(
493493
None, opts.get("request_timeout")
494494
)
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)
495499

496500
async def stream_file() -> AsyncIterator[bytes]:
497501
# pyqwest raises the builtin TimeoutError; keep the httpx
498502
# exception the streamed-read contract established.
499503
try:
500-
async with api_client.get_async_httpx_client().stream(
504+
async with stream_client.get_async_httpx_client().stream(
501505
method="GET",
502506
url=f"/volumecontent/{self._volume_id}/file",
503507
params=params,

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -464,8 +464,8 @@ def read_file(
464464
:param path: Path to the file
465465
:param format: Format of the file content—`text` by default
466466
:param stream_idle_timeout: Deprecated and ignored. A stalled streamed
467-
read is bounded by a transport-wide idle read timeout instead,
468-
which resets on every chunk.
467+
read is bounded by a transport-wide idle read timeout instead
468+
(60 seconds), which resets on every chunk.
469469
:param opts: Connection options
470470
471471
:return: File content as string, bytes, or iterator of bytes
@@ -489,12 +489,16 @@ def read_file(
489489
stream_timeout = VolumeConnectionConfig._get_request_timeout(
490490
None, opts.get("request_timeout")
491491
)
492+
# The streaming transport carries the idle read timeout; the
493+
# regular one must not (it would cut off slow uploads and
494+
# responses), so streamed reads get their own client.
495+
stream_client = get_volume_api_client(config, for_streaming=True)
492496

493497
def stream_file() -> Iterator[bytes]:
494498
# pyqwest raises the builtin TimeoutError; keep the httpx
495499
# exception the streamed-read contract established.
496500
try:
497-
with api_client.get_httpx_client().stream(
501+
with stream_client.get_httpx_client().stream(
498502
method="GET",
499503
url=f"/volumecontent/{self._volume_id}/file",
500504
params=params,

packages/python-sdk/tests/test_volume_client.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,12 @@ async def get_transports():
135135
CHUNK = b"x" * 1024
136136

137137

138-
def _start_volume_file_server(chunk_delays: List[float]) -> str:
138+
def _start_volume_file_server(
139+
chunk_delays: List[float], ttfb_delay: float = 0.0
140+
) -> str:
139141
"""One-shot HTTP server streaming a chunked volume-file body, sleeping
140-
``chunk_delays[i]`` before sending chunk ``i``. Returns its base URL."""
142+
``ttfb_delay`` before the response head and ``chunk_delays[i]`` before
143+
sending chunk ``i``. Returns its base URL."""
141144
sock = socket.socket()
142145
sock.bind(("127.0.0.1", 0))
143146
sock.listen(1)
@@ -148,6 +151,7 @@ def serve():
148151
conn, _ = sock.accept()
149152
while b"\r\n\r\n" not in conn.recv(65536):
150153
pass
154+
time.sleep(ttfb_delay)
151155
conn.sendall(
152156
b"HTTP/1.1 200 OK\r\n"
153157
b"Content-Type: application/octet-stream\r\n"
@@ -171,8 +175,8 @@ def serve():
171175
def short_read_timeout(monkeypatch):
172176
"""Rebuild the volume transports with a short idle read timeout."""
173177
reset_volume_transports()
174-
monkeypatch.setattr(client_sync, "FILE_TIMEOUT", 0.3)
175-
monkeypatch.setattr(client_async, "FILE_TIMEOUT", 0.3)
178+
monkeypatch.setattr(client_sync, "READ_TIMEOUT", 0.3)
179+
monkeypatch.setattr(client_async, "READ_TIMEOUT", 0.3)
176180
yield 0.3
177181
reset_volume_transports()
178182

@@ -231,3 +235,48 @@ async def run():
231235
return received
232236

233237
assert asyncio.run(run()) == [CHUNK]
238+
239+
240+
def test_stream_transport_is_separate_from_regular_transport():
241+
# reqwest's read timer keeps running while a request body is sent and
242+
# while waiting for the response head, so the idle read timeout lives on
243+
# a dedicated streaming transport — putting it on the shared one would
244+
# cut off uploads and slow unary responses longer than the idle bound.
245+
reset_volume_transports()
246+
config = VolumeConnectionConfig(token="vol-token")
247+
248+
try:
249+
regular = get_sync_transport(config)
250+
streaming = get_sync_transport(config, for_streaming=True)
251+
assert regular is not streaming
252+
assert get_sync_transport(config) is regular
253+
assert get_sync_transport(config, for_streaming=True) is streaming
254+
255+
async_regular = get_async_transport(config)
256+
async_streaming = get_async_transport(config, for_streaming=True)
257+
assert async_regular is not async_streaming
258+
finally:
259+
reset_volume_transports()
260+
261+
262+
def test_sync_non_stream_read_survives_response_slower_than_idle_timeout(
263+
short_read_timeout,
264+
):
265+
# Non-streamed requests go through the regular transport, which has no
266+
# idle read timeout: a server that takes longer than the streaming idle
267+
# bound to start responding must not be cut off.
268+
api_url = _start_volume_file_server([0.0], ttfb_delay=short_read_timeout * 3)
269+
volume = Volume(volume_id="v1", name="test", token="vol-token")
270+
271+
assert volume.read_file("file.bin", format="bytes", api_url=api_url) == CHUNK
272+
273+
274+
def test_sync_stream_response_head_is_bounded_by_idle_timeout(short_read_timeout):
275+
# For streamed reads the idle read timeout also bounds waiting for the
276+
# response head (like the JS SDK's handshake timeout on stream start).
277+
api_url = _start_volume_file_server([0.0], ttfb_delay=5.0)
278+
volume = Volume(volume_id="v1", name="test", token="vol-token")
279+
280+
stream = volume.read_file("file.bin", format="stream", api_url=api_url)
281+
with pytest.raises(httpx.ReadTimeout):
282+
next(iter(stream))

0 commit comments

Comments
 (0)