Skip to content

Commit c195691

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 532ac7a commit c195691

14 files changed

Lines changed: 495 additions & 297 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: 60 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,22 @@
11
import asyncio
22
import threading
3-
import weakref
43
from typing import Dict, Optional, Tuple, Union
54

65
import httpx
76

8-
from httpx._types import ProxyTypes
97
from pyqwest import HTTPTransport, Request, Response
108
from pyqwest.httpx import AsyncPyqwestTransport
119
from pyqwest.middleware.retry import RetryTransport
1210

1311
from e2b.api import (
1412
AsyncApiClient,
1513
connection_retries,
16-
limits,
14+
make_async_logging_event_hooks,
1715
pool_idle_timeout,
1816
pool_max_idle_per_host,
1917
proxy_to_url,
2018
)
21-
from e2b.connection_config import ConnectionConfig
22-
23-
TransportKey = Tuple[bool, Optional[ProxyTypes]]
19+
from e2b.connection_config import READ_TIMEOUT, ConnectionConfig
2420

2521

2622
def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient:
@@ -68,28 +64,34 @@ def should_retry_response(
6864
return isinstance(response, ConnectionError)
6965

7066

71-
def retrying_http_transport(proxy_url: Optional[str]) -> ConnectionRetryTransport:
67+
def retrying_http_transport(
68+
proxy_url: Optional[str], read_timeout: Optional[float] = None
69+
) -> ConnectionRetryTransport:
7270
"""A fresh pyqwest transport (= its own connection pool) with the SDK's
7371
shared tuning — system CA certs (without which TLS through an
7472
intercepting proxy fails), the httpx-equivalent pool limits, and
75-
connect-only retries. The REST API and envd RPC stacks each cache their
76-
own instances (pool unification is a follow-up)."""
73+
connect-only retries. The REST API, envd RPC, and envd HTTP API stacks
74+
each cache their own instances (pool unification is a follow-up).
75+
76+
``read_timeout`` bounds every read on the transport's connections; see
77+
:func:`get_envd_transport` for when that is (and isn't) appropriate."""
7778
return ConnectionRetryTransport(
7879
HTTPTransport(
7980
tls_include_system_certs=True,
8081
proxy=proxy_url,
8182
pool_idle_timeout=pool_idle_timeout,
8283
pool_max_idle_per_host=pool_max_idle_per_host,
84+
read_timeout=read_timeout,
8385
),
8486
max_retries=connection_retries,
8587
)
8688

8789

8890
_transport_lock = threading.Lock()
8991
# One transport (= one connection pool) per proxy; None is the direct pool.
90-
# pyqwest's I/O runs on its own Rust runtime, so unlike the httpx envd
91-
# transports below, the transport is not bound to an event loop and the
92-
# cache is process-global rather than per-loop.
92+
# pyqwest's I/O runs on its own Rust runtime, so unlike the httpx transports
93+
# they replaced, the transports are not bound to an event loop and the
94+
# caches are process-global rather than per-loop.
9395
_transports: Dict[Optional[str], "AsyncApiPyqwestTransport"] = {}
9496

9597

@@ -106,38 +108,52 @@ def get_transport(config: ConnectionConfig) -> "AsyncApiPyqwestTransport":
106108
return transport
107109

108110

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

122115

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

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

Lines changed: 85 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,44 @@
33
import httpx
44
import threading
55

6-
from httpx._types import ProxyTypes
76
from pyqwest import SyncHTTPTransport, SyncRequest, SyncResponse
87
from pyqwest.httpx import PyqwestTransport
98
from pyqwest.middleware.retry import SyncRetryTransport
109

1110
from e2b.api import (
1211
ApiClient,
1312
connection_retries,
14-
limits,
13+
make_logging_event_hooks,
1514
pool_idle_timeout,
1615
pool_max_idle_per_host,
1716
proxy_to_url,
1817
)
19-
from e2b.connection_config import ConnectionConfig
20-
21-
TransportKey = Tuple[bool, Optional[ProxyTypes]]
18+
from e2b.connection_config import READ_TIMEOUT, ConnectionConfig
2219

2320

2421
def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient:
2522
return ApiClient(config, transport=get_transport(config), **kwargs)
2623

2724

25+
class _SyncOnlyStream(httpx.SyncByteStream):
26+
"""Present a dual sync/async httpx request stream as sync-only.
27+
28+
httpx's ``MultipartStream`` (``files=`` uploads) implements both
29+
``SyncByteStream`` and ``AsyncByteStream``; the stock adapter's content
30+
conversion matches ``AsyncByteStream`` first and raises
31+
``TypeError("unreachable")`` from inside the body iterator, which
32+
surfaces as a ``WriteError`` mid-request."""
33+
34+
def __init__(self, stream: httpx.SyncByteStream):
35+
self._stream = stream
36+
37+
def __iter__(self):
38+
return iter(self._stream)
39+
40+
def close(self) -> None:
41+
self._stream.close()
42+
43+
2844
class ApiPyqwestTransport(PyqwestTransport):
2945
"""The SDK's tweaks on the stock pyqwest httpx adapter.
3046
@@ -42,6 +58,13 @@ class ApiPyqwestTransport(PyqwestTransport):
4258
def handle_request(self, request: httpx.Request) -> httpx.Response:
4359
if "host" in request.headers:
4460
del request.headers["host"]
61+
stream = request.stream
62+
if (
63+
isinstance(stream, httpx.SyncByteStream)
64+
and isinstance(stream, httpx.AsyncByteStream)
65+
and not isinstance(stream, httpx.ByteStream)
66+
):
67+
request.stream = _SyncOnlyStream(stream)
4568
try:
4669
return super().handle_request(request)
4770
except TimeoutError as e:
@@ -64,27 +87,33 @@ def should_retry_response(
6487
return isinstance(response, ConnectionError)
6588

6689

67-
def retrying_http_transport(proxy_url: Optional[str]) -> ConnectionRetryTransport:
90+
def retrying_http_transport(
91+
proxy_url: Optional[str], read_timeout: Optional[float] = None
92+
) -> ConnectionRetryTransport:
6893
"""A fresh pyqwest transport (= its own connection pool) with the SDK's
6994
shared tuning — system CA certs (without which TLS through an
7095
intercepting proxy fails), the httpx-equivalent pool limits, and
71-
connect-only retries. The REST API and envd RPC stacks each cache their
72-
own instances (pool unification is a follow-up)."""
96+
connect-only retries. The REST API, envd RPC, and envd HTTP API stacks
97+
each cache their own instances (pool unification is a follow-up).
98+
99+
``read_timeout`` bounds every read on the transport's connections; see
100+
:func:`get_envd_transport` for when that is (and isn't) appropriate."""
73101
return ConnectionRetryTransport(
74102
SyncHTTPTransport(
75103
tls_include_system_certs=True,
76104
proxy=proxy_url,
77105
pool_idle_timeout=pool_idle_timeout,
78106
pool_max_idle_per_host=pool_max_idle_per_host,
107+
read_timeout=read_timeout,
79108
),
80109
max_retries=connection_retries,
81110
)
82111

83112

84113
_transport_lock = threading.Lock()
85114
# One transport (= one connection pool) per proxy; None is the direct pool.
86-
# pyqwest transports are thread-safe, so unlike the httpx envd transports
87-
# below, the cache is process-global rather than per-thread.
115+
# pyqwest transports are thread-safe, so unlike the httpx transports they
116+
# replaced, the caches are process-global rather than per-thread.
88117
_transports: Dict[Optional[str], "ApiPyqwestTransport"] = {}
89118

90119

@@ -101,31 +130,53 @@ def get_transport(config: ConnectionConfig) -> "ApiPyqwestTransport":
101130
return transport
102131

103132

104-
class EnvdTransportWithLogger(httpx.HTTPTransport):
105-
_thread_local = threading.local()
106-
107-
@property
108-
def pool(self):
109-
return self._pool
133+
# One transport per (proxy, streaming) pair, separate from the REST API
134+
# pools — envd traffic goes to per-sandbox hosts.
135+
_envd_transports: Dict[Tuple[Optional[str], bool], "ApiPyqwestTransport"] = {}
110136

111137

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

packages/python-sdk/e2b/connection_config.py

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

1212
REQUEST_TIMEOUT: float = 60.0 # 60 seconds
1313

14+
# Idle bound for every read on the streaming envd file-transfer transport:
15+
# the transfer is aborted when no bytes at all arrive for this long. It
16+
# resets on each chunk, so it never limits total transfer time — only a
17+
# fully stalled stream. Matches the previous default stream idle timeout
18+
# (the request timeout).
19+
READ_TIMEOUT: float = 60.0 # 60 seconds
20+
1421
KEEPALIVE_PING_INTERVAL_SEC = 50 # 50 seconds
1522
KEEPALIVE_PING_HEADER = "Keepalive-Ping-Interval"
1623

0 commit comments

Comments
 (0)