Skip to content

Commit aeb19ea

Browse files
mishushakovclaude
andcommitted
feat(python-sdk): bound volume streams with a 60s idle read timeout on a dedicated transport
reqwest's read timer keeps ticking while a request body is sent and while waiting for the response head, so a shared read_timeout would kill slow uploads; streamed reads get their own transport with read_timeout=60s (JS SDK parity), uploads and unary calls stay unbounded-per-read. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e2d6a0c commit aeb19ea

6 files changed

Lines changed: 128 additions & 45 deletions

File tree

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

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

2121

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

5557

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

6365

64-
def get_transport(config: VolumeConnectionConfig) -> AsyncApiPyqwestTransport:
65-
"""The shared pyqwest-backed httpx transport for volume content API calls.
66+
def get_transport(
67+
config: VolumeConnectionConfig, *, for_streaming: bool = False
68+
) -> AsyncApiPyqwestTransport:
69+
"""The shared pyqwest-backed httpx transports for volume content API calls.
6670
67-
``read_timeout`` is the idle bound on every read: it resets after each
68-
successful read, so it caps how long a streamed download may stall
69-
without limiting total transfer time. It is fixed per transport — the
70-
adapter's per-request timeouts are whole-request deadlines, and the sync
71-
adapter does not bound body reads at all.
71+
The streaming transport carries ``read_timeout``, the idle bound on every
72+
read: it resets after each successful read, so it caps how long a
73+
streamed download may stall without limiting total transfer time. It is
74+
fixed per transport — the adapter's per-request timeouts are
75+
whole-request deadlines, and the sync adapter does not bound body reads
76+
at all. Only streamed downloads use it: reqwest's read timer keeps
77+
running while a request body is sent and while waiting for the response
78+
head, so on the regular transport it would cut off uploads and slow
79+
unary responses longer than the idle bound (those stay bounded by their
80+
whole-request deadlines instead).
7281
"""
7382
proxy = proxy_to_config(config.proxy)
83+
key = (proxy, for_streaming)
7484
with _transport_lock:
75-
transport = _transports.get(proxy)
85+
transport = _transports.get(key)
7686
if transport is None:
7787
transport = AsyncApiPyqwestTransport(
7888
ConnectionRetryTransport(
@@ -81,10 +91,10 @@ def get_transport(config: VolumeConnectionConfig) -> AsyncApiPyqwestTransport:
8191
proxy=proxy.to_pyqwest() if proxy is not None else None,
8292
pool_idle_timeout=pool_idle_timeout,
8393
pool_max_idle_per_host=pool_max_idle_per_host,
84-
read_timeout=FILE_TIMEOUT,
94+
read_timeout=READ_TIMEOUT if for_streaming else None,
8595
),
8696
max_retries=connection_retries,
8797
)
8898
)
89-
_transports[proxy] = transport
99+
_transports[key] = transport
90100
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
@@ -16,10 +16,12 @@
1616
from e2b.api.metadata import default_headers
1717
from e2b.exceptions import AuthenticationException
1818
from e2b.volume.client.client import AuthenticatedClient as VolumeApiClient
19-
from e2b.volume.connection_config import FILE_TIMEOUT, VolumeConnectionConfig
19+
from e2b.volume.connection_config import READ_TIMEOUT, VolumeConnectionConfig
2020

2121

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

5557

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

6264

63-
def get_transport(config: VolumeConnectionConfig) -> ApiPyqwestTransport:
64-
"""The shared pyqwest-backed httpx transport for volume content API calls.
65+
def get_transport(
66+
config: VolumeConnectionConfig, *, for_streaming: bool = False
67+
) -> ApiPyqwestTransport:
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 = proxy_to_config(config.proxy)
82+
key = (proxy, for_streaming)
7383
with _transport_lock:
74-
transport = _transports.get(proxy)
84+
transport = _transports.get(key)
7585
if transport is None:
7686
transport = ApiPyqwestTransport(
7787
ConnectionRetryTransport(
@@ -80,10 +90,10 @@ def get_transport(config: VolumeConnectionConfig) -> ApiPyqwestTransport:
8090
proxy=proxy.to_pyqwest() if proxy is not None else None,
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] = transport
98+
_transports[key] = transport
8999
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)