Skip to content

Commit 77e563b

Browse files
mishushakovclaude
andcommitted
feat(python-sdk): move the envd HTTP API client onto pyqwest
Swap the per-thread/per-loop envd httpx clients (file transfers, health checks) for shared pyqwest-backed ones built from the same transport pieces as the REST client and envd RPC. Streamed downloads default to a dedicated transport whose 60s idle read timeout bounds stalls (reqwest's read timer ticks during body send/TTFB, so it can't live on the shared transport); explicit request timeouts become whole-transfer deadlines and streamed uploads carry no client-side timeout, matching the JS SDK. Present httpx's dual sync/async MultipartStream as sync-only — the stock adapter matches AsyncByteStream first and kills multipart uploads mid-request. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent cdd5314 commit 77e563b

15 files changed

Lines changed: 476 additions & 298 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@e2b/python-sdk": minor
3+
---
4+
5+
Move the envd HTTP API client (sandbox file transfers, health checks) onto
6+
[`pyqwest`](https://pypi.org/project/pyqwest/) via its httpx-compatible
7+
transport adapter. envd RPC already runs on pyqwest through `connectrpc`, so
8+
all sandbox traffic now shares one HTTP stack built from the same transport
9+
pieces (with separate connection pools per use).
10+
11+
The per-thread (sync) and per-loop (async) envd httpx clients are gone: the
12+
pyqwest transports are thread-safe and loop-independent, so a single client
13+
per module serves all threads and event loops.
14+
15+
Timeout semantics through the adapter:
16+
17+
- Streamed downloads (`files.read(format="stream")`): a `request_timeout`
18+
set explicitly for the call is the deadline for the whole transfer — by
19+
default the transfer is unbounded in total, as before. A stalled stream is
20+
reclaimed by a 60-second idle read timeout that resets on every chunk.
21+
`stream_idle_timeout` keeps working on the async client (applied per
22+
read); the sync client cannot interrupt a blocking read, so it relies on
23+
the transport-wide idle bound and now ignores the parameter.
24+
- Uploads: a buffered upload is bounded by `request_timeout` as a
25+
whole-request deadline, and a streamed (file-like) upload carries no
26+
client-side timeout (a stalled one is bounded server-side by envd's idle
27+
read timeout) — both matching the JS SDK.

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

Lines changed: 61 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import asyncio
22
import threading
3-
import weakref
43
from typing import Dict, Optional, Tuple, Union
54

65
import httpx
@@ -13,14 +12,12 @@
1312
AsyncApiClient,
1413
ProxyConfig,
1514
connection_retries,
16-
limits,
15+
make_async_logging_event_hooks,
1716
pool_idle_timeout,
1817
pool_max_idle_per_host,
1918
proxy_to_config,
2019
)
21-
from e2b.connection_config import ConnectionConfig, ProxyTypes
22-
23-
TransportKey = Tuple[bool, Optional[ProxyTypes]]
20+
from e2b.connection_config import READ_TIMEOUT, ConnectionConfig
2421

2522

2623
def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient:
@@ -69,13 +66,17 @@ def should_retry_response(
6966

7067

7168
def retrying_http_transport(
72-
proxy: Optional[ProxyConfig],
69+
proxy: Optional[ProxyConfig], read_timeout: Optional[float] = None
7370
) -> ConnectionRetryTransport:
7471
"""A fresh pyqwest transport (= its own connection pool) with the SDK's
7572
shared tuning — system CA certs (without which TLS through an
7673
intercepting proxy fails), the httpx-equivalent pool limits, and
77-
connect-only retries. The REST API and envd RPC stacks each cache their
78-
own instances (pool unification is a follow-up).
74+
connect-only retries. The REST API, envd RPC, and envd HTTP API stacks
75+
each cache their own instances (pool unification is a follow-up).
76+
77+
``read_timeout`` bounds every read on the transport's connections; see
78+
:func:`get_envd_transport` for when that is (and isn't) appropriate.
79+
7980
Requests are logged by pyqwest itself on the ``pyqwest.access`` and
8081
``pyqwest`` loggers at ``DEBUG`` (off unless enabled) — the transport-level
8182
diagnostics httpcore used to provide. The SDK's own ``logger`` option is
@@ -86,16 +87,17 @@ def retrying_http_transport(
8687
proxy=proxy.to_pyqwest() if proxy is not None else None,
8788
pool_idle_timeout=pool_idle_timeout,
8889
pool_max_idle_per_host=pool_max_idle_per_host,
90+
read_timeout=read_timeout,
8991
),
9092
max_retries=connection_retries,
9193
)
9294

9395

9496
_transport_lock = threading.Lock()
9597
# One transport (= one connection pool) per proxy; None is the direct pool.
96-
# pyqwest's I/O runs on its own Rust runtime, so unlike the httpx envd
97-
# transports below, the transport is not bound to an event loop and the
98-
# cache is process-global rather than per-loop.
98+
# pyqwest's I/O runs on its own Rust runtime, so unlike the httpx transports
99+
# they replaced, the transports are not bound to an event loop and the
100+
# caches are process-global rather than per-loop.
99101
_transports: Dict[Optional[ProxyConfig], "AsyncApiPyqwestTransport"] = {}
100102

101103

@@ -112,38 +114,54 @@ def get_transport(config: ConnectionConfig) -> "AsyncApiPyqwestTransport":
112114
return transport
113115

114116

115-
class AsyncEnvdTransportWithLogger(httpx.AsyncHTTPTransport):
116-
# Keyed weakly by the event loop object itself, not id(loop) — CPython
117-
# reuses object ids, so a new loop could otherwise inherit a transport
118-
# bound to a previous, closed loop.
119-
_instances: weakref.WeakKeyDictionary[
120-
asyncio.AbstractEventLoop,
121-
Dict[TransportKey, "AsyncEnvdTransportWithLogger"],
122-
] = weakref.WeakKeyDictionary()
123-
124-
@property
125-
def pool(self):
126-
return self._pool
117+
# One transport per (proxy, streaming) pair, separate from the REST API
118+
# pools — envd traffic goes to per-sandbox hosts.
119+
_envd_transports: Dict[
120+
Tuple[Optional[ProxyConfig], bool], "AsyncApiPyqwestTransport"
121+
] = {}
127122

128123

129124
def get_envd_transport(
130-
config: ConnectionConfig, http2: bool = True
131-
) -> AsyncEnvdTransportWithLogger:
132-
loop = asyncio.get_running_loop()
133-
loop_instances = AsyncEnvdTransportWithLogger._instances.get(loop)
134-
if loop_instances is None:
135-
loop_instances = {}
136-
AsyncEnvdTransportWithLogger._instances[loop] = loop_instances
137-
138-
key: TransportKey = (http2, config.proxy)
139-
transport = loop_instances.get(key)
140-
if transport is None:
141-
transport = AsyncEnvdTransportWithLogger(
142-
limits=limits,
143-
proxy=config.proxy,
144-
http2=http2,
145-
retries=connection_retries,
146-
)
147-
loop_instances[key] = transport
148-
149-
return transport
125+
config: ConnectionConfig, *, for_streaming: bool = False
126+
) -> "AsyncApiPyqwestTransport":
127+
"""The shared pyqwest-backed httpx transports for the envd HTTP API
128+
(file transfers, health checks).
129+
130+
The streaming transport carries ``read_timeout``, the idle bound on
131+
every read: it resets after each successful read, so it caps how long a
132+
streamed download may stall without limiting total transfer time. It is
133+
fixed per transport — the adapter's per-request timeouts are
134+
whole-request deadlines. Only streamed downloads use it: reqwest's read
135+
timer keeps running while a request body is sent and while waiting for
136+
the response head, so on the regular transport it would cut off uploads
137+
and slow unary responses longer than the idle bound (those stay bounded
138+
by their whole-request deadlines instead).
139+
"""
140+
proxy = proxy_to_config(config.proxy)
141+
key = (proxy, for_streaming)
142+
with _transport_lock:
143+
transport = _envd_transports.get(key)
144+
if transport is None:
145+
transport = AsyncApiPyqwestTransport(
146+
retrying_http_transport(
147+
proxy,
148+
read_timeout=READ_TIMEOUT if for_streaming else None,
149+
)
150+
)
151+
_envd_transports[key] = transport
152+
return transport
153+
154+
155+
def get_envd_api(
156+
config: ConnectionConfig, base_url: str, *, for_streaming: bool = False
157+
) -> httpx.AsyncClient:
158+
"""An httpx client for a sandbox's envd HTTP API (file transfers, health
159+
checks) on the shared pyqwest transports. The client itself is a cheap
160+
stateless wrapper — one per consumer is fine — while the pooled transport
161+
underneath is shared and loop-independent."""
162+
return httpx.AsyncClient(
163+
base_url=base_url,
164+
transport=get_envd_transport(config, for_streaming=for_streaming),
165+
headers=config.sandbox_headers,
166+
event_hooks=make_async_logging_event_hooks(config.logger),
167+
)

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

Lines changed: 58 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,12 @@
1111
ApiClient,
1212
ProxyConfig,
1313
connection_retries,
14-
limits,
14+
make_logging_event_hooks,
1515
pool_idle_timeout,
1616
pool_max_idle_per_host,
1717
proxy_to_config,
1818
)
19-
from e2b.connection_config import ConnectionConfig, ProxyTypes
20-
21-
TransportKey = Tuple[bool, Optional[ProxyTypes]]
19+
from e2b.connection_config import READ_TIMEOUT, ConnectionConfig
2220

2321

2422
def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient:
@@ -65,13 +63,17 @@ def should_retry_response(
6563

6664

6765
def retrying_http_transport(
68-
proxy: Optional[ProxyConfig],
66+
proxy: Optional[ProxyConfig], read_timeout: Optional[float] = None
6967
) -> ConnectionRetryTransport:
7068
"""A fresh pyqwest transport (= its own connection pool) with the SDK's
7169
shared tuning — system CA certs (without which TLS through an
7270
intercepting proxy fails), the httpx-equivalent pool limits, and
73-
connect-only retries. The REST API and envd RPC stacks each cache their
74-
own instances (pool unification is a follow-up).
71+
connect-only retries. The REST API, envd RPC, and envd HTTP API stacks
72+
each cache their own instances (pool unification is a follow-up).
73+
74+
``read_timeout`` bounds every read on the transport's connections; see
75+
:func:`get_envd_transport` for when that is (and isn't) appropriate.
76+
7577
Requests are logged by pyqwest itself on the ``pyqwest.access`` and
7678
``pyqwest`` loggers at ``DEBUG`` (off unless enabled) — the transport-level
7779
diagnostics httpcore used to provide. The SDK's own ``logger`` option is
@@ -82,15 +84,16 @@ def retrying_http_transport(
8284
proxy=proxy.to_pyqwest() if proxy is not None else None,
8385
pool_idle_timeout=pool_idle_timeout,
8486
pool_max_idle_per_host=pool_max_idle_per_host,
87+
read_timeout=read_timeout,
8588
),
8689
max_retries=connection_retries,
8790
)
8891

8992

9093
_transport_lock = threading.Lock()
9194
# One transport (= one connection pool) per proxy; None is the direct pool.
92-
# pyqwest transports are thread-safe, so unlike the httpx envd transports
93-
# below, the cache is process-global rather than per-thread.
95+
# pyqwest transports are thread-safe, so unlike the httpx transports they
96+
# replaced, the caches are process-global rather than per-thread.
9497
_transports: Dict[Optional[ProxyConfig], "ApiPyqwestTransport"] = {}
9598

9699

@@ -107,31 +110,53 @@ def get_transport(config: ConnectionConfig) -> "ApiPyqwestTransport":
107110
return transport
108111

109112

110-
class EnvdTransportWithLogger(httpx.HTTPTransport):
111-
_thread_local = threading.local()
112-
113-
@property
114-
def pool(self):
115-
return self._pool
113+
# One transport per (proxy, streaming) pair, separate from the REST API
114+
# pools — envd traffic goes to per-sandbox hosts.
115+
_envd_transports: Dict[Tuple[Optional[ProxyConfig], bool], "ApiPyqwestTransport"] = {}
116116

117117

118118
def get_envd_transport(
119-
config: ConnectionConfig, http2: bool = True
120-
) -> EnvdTransportWithLogger:
121-
instances: Dict[TransportKey, EnvdTransportWithLogger] = getattr(
122-
EnvdTransportWithLogger._thread_local, "instances", {}
123-
)
124-
key: TransportKey = (http2, config.proxy)
125-
cached = instances.get(key)
126-
if cached is not None:
127-
return cached
128-
129-
transport = EnvdTransportWithLogger(
130-
limits=limits,
131-
proxy=config.proxy,
132-
http2=http2,
133-
retries=connection_retries,
119+
config: ConnectionConfig, *, for_streaming: bool = False
120+
) -> "ApiPyqwestTransport":
121+
"""The shared pyqwest-backed httpx transports for the envd HTTP API
122+
(file transfers, health checks).
123+
124+
The streaming transport carries ``read_timeout``, the idle bound on
125+
every read: it resets after each successful read, so it caps how long a
126+
streamed download may stall without limiting total transfer time. It is
127+
fixed per transport — the adapter's per-request timeouts are
128+
whole-request deadlines, and the sync adapter does not bound body reads
129+
at all. Only streamed downloads use it: reqwest's read timer keeps
130+
running while a request body is sent and while waiting for the response
131+
head, so on the regular transport it would cut off uploads and slow
132+
unary responses longer than the idle bound (those stay bounded by their
133+
whole-request deadlines instead).
134+
"""
135+
proxy = proxy_to_config(config.proxy)
136+
key = (proxy, for_streaming)
137+
with _transport_lock:
138+
transport = _envd_transports.get(key)
139+
if transport is None:
140+
transport = ApiPyqwestTransport(
141+
retrying_http_transport(
142+
proxy,
143+
read_timeout=READ_TIMEOUT if for_streaming else None,
144+
)
145+
)
146+
_envd_transports[key] = transport
147+
return transport
148+
149+
150+
def get_envd_api(
151+
config: ConnectionConfig, base_url: str, *, for_streaming: bool = False
152+
) -> httpx.Client:
153+
"""An httpx client for a sandbox's envd HTTP API (file transfers, health
154+
checks) on the shared pyqwest transports. The client itself is a cheap
155+
stateless wrapper — one per consumer is fine — while the pooled transport
156+
underneath is shared and thread-safe."""
157+
return httpx.Client(
158+
base_url=base_url,
159+
transport=get_envd_transport(config, for_streaming=for_streaming),
160+
headers=config.sandbox_headers,
161+
event_hooks=make_logging_event_hooks(config.logger),
134162
)
135-
instances[key] = transport
136-
EnvdTransportWithLogger._thread_local.instances = instances
137-
return transport

packages/python-sdk/e2b/connection_config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@
2121

2222
REQUEST_TIMEOUT: float = 60.0 # 60 seconds
2323

24+
# Idle bound for every read on the streaming envd file-transfer transport:
25+
# the transfer is aborted when no bytes at all arrive for this long. It
26+
# resets on each chunk, so it never limits total transfer time — only a
27+
# fully stalled stream. Matches the previous default stream idle timeout
28+
# (the request timeout).
29+
READ_TIMEOUT: float = 60.0 # 60 seconds
30+
2431
KEEPALIVE_PING_INTERVAL_SEC = 50 # 50 seconds
2532
KEEPALIVE_PING_HEADER = "Keepalive-Ping-Interval"
2633

packages/python-sdk/e2b/envd/interceptors.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,13 @@ class LoggingInterceptor:
105105
streamed message at DEBUG — mirroring the httpx event hooks used for the
106106
REST API and file transfer requests.
107107
108-
Upstreamed to pyqwest as a logging middleware
109-
(https://github.com/curioswitch/pyqwest/pull/192); this interceptor stays
110-
until that ships in a pyqwest release the SDK can depend on.
108+
pyqwest logs requests itself on the ``pyqwest.access`` and ``pyqwest``
109+
loggers, but those are process-wide loggers that cannot carry the
110+
per-sandbox logger this option gives callers, and they see only the HTTP
111+
exchange — not the streamed messages, nor the Connect error code of a
112+
stream that fails inside a ``200 OK`` response. This interceptor is what
113+
serves the ``logger`` option; the pyqwest loggers sit below it as
114+
transport-level diagnostics.
111115
"""
112116

113117
def __init__(self, logger: logging.Logger, base_url: str):

packages/python-sdk/e2b/sandbox/filesystem/filesystem.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import gzip
23
import re
34
from dataclasses import dataclass, field
@@ -181,6 +182,11 @@ def __iter__(self) -> Iterator[bytes]:
181182
def __next__(self) -> bytes:
182183
try:
183184
return next(self._iterator)
185+
except TimeoutError as e:
186+
# The transport's idle read timeout is the builtin TimeoutError
187+
# under pyqwest; keep the documented httpx exception.
188+
self.close()
189+
raise httpx.ReadTimeout(str(e)) from e
184190
except BaseException:
185191
# Covers normal end (StopIteration) and read errors alike.
186192
self.close()
@@ -219,17 +225,31 @@ class AsyncFileStreamReader(AsyncIterator[bytes]):
219225
...
220226
"""
221227

222-
def __init__(self, response: httpx.Response):
228+
def __init__(self, response: httpx.Response, idle_timeout: Optional[float] = None):
223229
self._response = response
224230
self._iterator = response.aiter_bytes()
231+
# An explicit per-call idle bound, applied around each read with
232+
# `wait_for` (the transport-wide idle read timeout covers the
233+
# default case; see the flavor `read` implementations).
234+
self._idle_timeout = idle_timeout
225235
self._closed = False
226236

227237
def __aiter__(self) -> AsyncIterator[bytes]:
228238
return self
229239

230240
async def __anext__(self) -> bytes:
231241
try:
232-
return await self._iterator.__anext__()
242+
read = self._iterator.__anext__()
243+
if self._idle_timeout:
244+
return await asyncio.wait_for(read, self._idle_timeout)
245+
return await read
246+
except (TimeoutError, asyncio.TimeoutError) as e:
247+
# Both the transport's idle read timeout (the builtin
248+
# TimeoutError under pyqwest) and wait_for's expiry (a distinct
249+
# asyncio.TimeoutError until 3.11); keep the documented httpx
250+
# exception.
251+
await self.aclose()
252+
raise httpx.ReadTimeout(str(e)) from e
233253
except BaseException:
234254
# Covers normal end (StopAsyncIteration) and read errors alike.
235255
await self.aclose()

0 commit comments

Comments
 (0)