Skip to content

Commit f9cf7f2

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 c3a562c commit f9cf7f2

15 files changed

Lines changed: 476 additions & 300 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 & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
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
@@ -14,14 +12,12 @@
1412
AsyncApiClient,
1513
ProxyConfig,
1614
connection_retries,
17-
limits,
15+
make_async_logging_event_hooks,
1816
pool_idle_timeout,
1917
pool_max_idle_per_host,
2018
proxy_to_config,
2119
)
22-
from e2b.connection_config import ConnectionConfig
23-
24-
TransportKey = Tuple[bool, Optional[ProxyTypes]]
20+
from e2b.connection_config import READ_TIMEOUT, ConnectionConfig
2521

2622

2723
def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient:
@@ -70,13 +66,17 @@ def should_retry_response(
7066

7167

7268
def retrying_http_transport(
73-
proxy: Optional[ProxyConfig],
69+
proxy: Optional[ProxyConfig], read_timeout: Optional[float] = None
7470
) -> ConnectionRetryTransport:
7571
"""A fresh pyqwest transport (= its own connection pool) with the SDK's
7672
shared tuning — system CA certs (without which TLS through an
7773
intercepting proxy fails), the httpx-equivalent pool limits, and
78-
connect-only retries. The REST API and envd RPC stacks each cache their
79-
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+
8080
Requests are logged by pyqwest itself on the ``pyqwest.access`` and
8181
``pyqwest`` loggers at ``DEBUG`` (off unless enabled) — the transport-level
8282
diagnostics httpcore used to provide. The SDK's own ``logger`` option is
@@ -87,16 +87,17 @@ def retrying_http_transport(
8787
proxy=proxy.to_pyqwest() if proxy is not None else None,
8888
pool_idle_timeout=pool_idle_timeout,
8989
pool_max_idle_per_host=pool_max_idle_per_host,
90+
read_timeout=read_timeout,
9091
),
9192
max_retries=connection_retries,
9293
)
9394

9495

9596
_transport_lock = threading.Lock()
9697
# One transport (= one connection pool) per proxy; None is the direct pool.
97-
# pyqwest's I/O runs on its own Rust runtime, so unlike the httpx envd
98-
# transports below, the transport is not bound to an event loop and the
99-
# 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.
100101
_transports: Dict[Optional[ProxyConfig], "AsyncApiPyqwestTransport"] = {}
101102

102103

@@ -113,38 +114,54 @@ def get_transport(config: ConnectionConfig) -> "AsyncApiPyqwestTransport":
113114
return transport
114115

115116

116-
class AsyncEnvdTransportWithLogger(httpx.AsyncHTTPTransport):
117-
# Keyed weakly by the event loop object itself, not id(loop) — CPython
118-
# reuses object ids, so a new loop could otherwise inherit a transport
119-
# bound to a previous, closed loop.
120-
_instances: weakref.WeakKeyDictionary[
121-
asyncio.AbstractEventLoop,
122-
Dict[TransportKey, "AsyncEnvdTransportWithLogger"],
123-
] = weakref.WeakKeyDictionary()
124-
125-
@property
126-
def pool(self):
127-
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+
] = {}
128122

129123

130124
def get_envd_transport(
131-
config: ConnectionConfig, http2: bool = True
132-
) -> AsyncEnvdTransportWithLogger:
133-
loop = asyncio.get_running_loop()
134-
loop_instances = AsyncEnvdTransportWithLogger._instances.get(loop)
135-
if loop_instances is None:
136-
loop_instances = {}
137-
AsyncEnvdTransportWithLogger._instances[loop] = loop_instances
138-
139-
key: TransportKey = (http2, config.proxy)
140-
transport = loop_instances.get(key)
141-
if transport is None:
142-
transport = AsyncEnvdTransportWithLogger(
143-
limits=limits,
144-
proxy=config.proxy,
145-
http2=http2,
146-
retries=connection_retries,
147-
)
148-
loop_instances[key] = transport
149-
150-
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 & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
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
@@ -12,14 +11,12 @@
1211
ApiClient,
1312
ProxyConfig,
1413
connection_retries,
15-
limits,
14+
make_logging_event_hooks,
1615
pool_idle_timeout,
1716
pool_max_idle_per_host,
1817
proxy_to_config,
1918
)
20-
from e2b.connection_config import ConnectionConfig
21-
22-
TransportKey = Tuple[bool, Optional[ProxyTypes]]
19+
from e2b.connection_config import READ_TIMEOUT, ConnectionConfig
2320

2421

2522
def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient:
@@ -66,13 +63,17 @@ def should_retry_response(
6663

6764

6865
def retrying_http_transport(
69-
proxy: Optional[ProxyConfig],
66+
proxy: Optional[ProxyConfig], read_timeout: Optional[float] = None
7067
) -> ConnectionRetryTransport:
7168
"""A fresh pyqwest transport (= its own connection pool) with the SDK's
7269
shared tuning — system CA certs (without which TLS through an
7370
intercepting proxy fails), the httpx-equivalent pool limits, and
74-
connect-only retries. The REST API and envd RPC stacks each cache their
75-
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+
7677
Requests are logged by pyqwest itself on the ``pyqwest.access`` and
7778
``pyqwest`` loggers at ``DEBUG`` (off unless enabled) — the transport-level
7879
diagnostics httpcore used to provide. The SDK's own ``logger`` option is
@@ -83,15 +84,16 @@ def retrying_http_transport(
8384
proxy=proxy.to_pyqwest() if proxy is not None else None,
8485
pool_idle_timeout=pool_idle_timeout,
8586
pool_max_idle_per_host=pool_max_idle_per_host,
87+
read_timeout=read_timeout,
8688
),
8789
max_retries=connection_retries,
8890
)
8991

9092

9193
_transport_lock = threading.Lock()
9294
# One transport (= one connection pool) per proxy; None is the direct pool.
93-
# pyqwest transports are thread-safe, so unlike the httpx envd transports
94-
# 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.
9597
_transports: Dict[Optional[ProxyConfig], "ApiPyqwestTransport"] = {}
9698

9799

@@ -108,31 +110,53 @@ def get_transport(config: ConnectionConfig) -> "ApiPyqwestTransport":
108110
return transport
109111

110112

111-
class EnvdTransportWithLogger(httpx.HTTPTransport):
112-
_thread_local = threading.local()
113-
114-
@property
115-
def pool(self):
116-
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"] = {}
117116

118117

119118
def get_envd_transport(
120-
config: ConnectionConfig, http2: bool = True
121-
) -> EnvdTransportWithLogger:
122-
instances: Dict[TransportKey, EnvdTransportWithLogger] = getattr(
123-
EnvdTransportWithLogger._thread_local, "instances", {}
124-
)
125-
key: TransportKey = (http2, config.proxy)
126-
cached = instances.get(key)
127-
if cached is not None:
128-
return cached
129-
130-
transport = EnvdTransportWithLogger(
131-
limits=limits,
132-
proxy=config.proxy,
133-
http2=http2,
134-
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),
135162
)
136-
instances[key] = transport
137-
EnvdTransportWithLogger._thread_local.instances = instances
138-
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

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):

0 commit comments

Comments
 (0)