Skip to content

Commit 6d0ee19

Browse files
mishushakovclaude
andcommitted
feat(python-sdk): move the REST API client onto pyqwest's httpx transport adapter
Swap the httpx-native HTTPTransport/AsyncHTTPTransport under the generated REST API client for pyqwest (Rust reqwest/hyper) via pyqwest.httpx. The httpx client surface is unchanged; the transports are now thread-safe and loop-independent, so the pool is cached process-wide per proxy. Strip the Host header httpx adds — forwarding it on HTTP/2 makes the API edge reset the stream with PROTOCOL_ERROR. envd traffic and the volume content client stay on httpx (the streaming download path needs httpx's per-read idle timeout, which the adapter's whole-request deadline can't express). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9ee4414 commit 6d0ee19

7 files changed

Lines changed: 481 additions & 147 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@e2b/python-sdk": minor
3+
---
4+
5+
Move the REST API client (sandbox lifecycle, listing, templates, volumes
6+
control plane) onto [`pyqwest`](https://pypi.org/project/pyqwest/) (Rust
7+
reqwest/hyper) via its httpx-compatible transport adapter, replacing the
8+
httpx-native `HTTPTransport`/`AsyncHTTPTransport`. The generated httpx client
9+
API is unchanged — only the transport underneath is swapped — so logging
10+
event hooks, per-request timeouts, and headers behave as before.
11+
12+
Because pyqwest transports are thread-safe and loop-independent (I/O runs on
13+
a Rust runtime), the API connection pool is now shared process-wide per
14+
proxy, instead of one pool per thread (sync) or per event loop (async).
15+
Connection-establishment failures are retried with backoff
16+
(`E2B_CONNECTION_RETRIES`, default 3), matching the connect-only retries of
17+
the previous transports.
18+
19+
`proxy` for API calls must now be a URL string (e.g.
20+
`proxy="http://user:pass@localhost:8030"`, scheme http, https, socks5, or
21+
socks5h); the richer `httpx.Proxy` objects are rejected with
22+
`InvalidArgumentException` rather than partially honored.
23+
24+
envd traffic (sandbox commands, filesystem, PTY, file transfers) is not
25+
affected and stays on its existing stack.

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

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

1212
import httpx
1313
from httpx import AsyncBaseTransport, BaseTransport, Limits, Timeout
14+
from httpx._types import ProxyTypes
1415

1516
from e2b.api.client.client import AuthenticatedClient
1617
from e2b.api.client.types import Response
1718
from e2b.api.metadata import default_headers
1819
from e2b.connection_config import ConnectionConfig
1920
from e2b.exceptions import (
2021
AuthenticationException,
22+
InvalidArgumentException,
2123
RateLimitException,
2224
SandboxException,
2325
)
@@ -70,6 +72,32 @@ async def on_response(response: Response) -> None:
7072

7173
connection_retries = int(os.getenv("E2B_CONNECTION_RETRIES", "3"))
7274

75+
# Mirror the httpx pool tuning above with pyqwest's equivalents for the REST
76+
# API transports. `pool_max_idle_per_host` is per host rather than httpx's
77+
# global idle cap, but API traffic goes to a single host, so the values map
78+
# directly; reqwest has no counterpart to `E2B_MAX_CONNECTIONS` (it does not
79+
# cap concurrent connections).
80+
pool_idle_timeout = float(os.getenv("E2B_KEEPALIVE_EXPIRY") or "300")
81+
pool_max_idle_per_host = int(os.getenv("E2B_MAX_KEEPALIVE_CONNECTIONS") or "20")
82+
83+
84+
def proxy_to_url(proxy: Optional[ProxyTypes]) -> Optional[str]:
85+
"""Narrow the ``proxy`` connection option to the proxy URL string pyqwest
86+
transports take (scheme http, https, socks5, or socks5h, credentials in
87+
the URL userinfo). The richer ``httpx.Proxy`` objects the httpx transports
88+
accepted (per-proxy auth, headers, TLS context) are rejected rather than
89+
partially honored."""
90+
if proxy is None:
91+
return None
92+
if isinstance(proxy, str):
93+
return proxy
94+
if isinstance(proxy, httpx.URL):
95+
return str(proxy)
96+
raise InvalidArgumentException(
97+
"E2B API calls support only URL-string proxies, "
98+
'e.g. proxy="http://user:pass@localhost:8030"'
99+
)
100+
73101

74102
@dataclass
75103
class SandboxCreateResponse:

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

Lines changed: 81 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
import asyncio
2+
import threading
23
import weakref
3-
from typing import Dict, Optional, Tuple
4+
from typing import Dict, Optional, Tuple, Union
45

56
import httpx
67

78
from httpx._types import ProxyTypes
8-
9-
from e2b.api import AsyncApiClient, connection_retries, limits
9+
from pyqwest import HTTPTransport, Request, Response
10+
from pyqwest.httpx import AsyncPyqwestTransport
11+
from pyqwest.middleware.retry import RetryTransport
12+
13+
from e2b.api import (
14+
AsyncApiClient,
15+
connection_retries,
16+
limits,
17+
pool_idle_timeout,
18+
pool_max_idle_per_host,
19+
proxy_to_url,
20+
)
1021
from e2b.connection_config import ConnectionConfig
1122

1223
TransportKey = Tuple[bool, Optional[ProxyTypes]]
@@ -20,31 +31,92 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient:
2031
)
2132

2233

23-
class AsyncTransportWithLogger(httpx.AsyncHTTPTransport):
34+
class AsyncApiPyqwestTransport(AsyncPyqwestTransport):
35+
"""Strip the ``Host`` header httpx adds to every request: hyper derives
36+
HTTP/1 ``Host`` and HTTP/2 ``:authority`` from the URL itself, and
37+
forwarding an explicit ``host`` header on an HTTP/2 connection makes the
38+
E2B API edge reset the stream with PROTOCOL_ERROR. (Custom ``Host``
39+
overrides are therefore not honored, matching hyper's URL-derived
40+
behavior.)"""
41+
42+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
43+
if "host" in request.headers:
44+
del request.headers["host"]
45+
return await super().handle_async_request(request)
46+
47+
48+
class ConnectionRetryTransport(RetryTransport):
49+
"""Retry only failures establishing the connection, matching the
50+
connect-only ``retries`` of the httpx transport this replaced: pyqwest
51+
raises the builtin ``ConnectionError`` only before the request was
52+
written, so these retries can never replay a request the API may have
53+
received. The retry middleware's default policy would otherwise also
54+
retry I/O errors and 429/5xx responses for idempotent methods."""
55+
56+
def should_retry_response(
57+
self, request: Request, response: Union[Response, Exception]
58+
) -> bool:
59+
return isinstance(response, ConnectionError)
60+
61+
62+
_transport_lock = threading.Lock()
63+
# One transport (= one connection pool) per proxy; None is the direct pool.
64+
# pyqwest's I/O runs on its own Rust runtime, so unlike the httpx envd
65+
# transports below, the transport is not bound to an event loop and the
66+
# cache is process-global rather than per-loop.
67+
_transports: Dict[Optional[str], "AsyncApiPyqwestTransport"] = {}
68+
69+
70+
def get_transport(config: ConnectionConfig) -> "AsyncApiPyqwestTransport":
71+
"""The shared pyqwest-backed httpx transport for REST API calls. For TLS
72+
connections ALPN negotiates the HTTP version (HTTP/2 against the E2B
73+
API), like the http2-enabled httpx transport this replaced."""
74+
proxy_url = proxy_to_url(config.proxy)
75+
with _transport_lock:
76+
transport = _transports.get(proxy_url)
77+
if transport is None:
78+
transport = AsyncApiPyqwestTransport(
79+
ConnectionRetryTransport(
80+
HTTPTransport(
81+
tls_include_system_certs=True,
82+
proxy=proxy_url,
83+
pool_idle_timeout=pool_idle_timeout,
84+
pool_max_idle_per_host=pool_max_idle_per_host,
85+
),
86+
max_retries=connection_retries,
87+
)
88+
)
89+
_transports[proxy_url] = transport
90+
return transport
91+
92+
93+
class AsyncEnvdTransportWithLogger(httpx.AsyncHTTPTransport):
2494
# Keyed weakly by the event loop object itself, not id(loop) — CPython
2595
# reuses object ids, so a new loop could otherwise inherit a transport
2696
# bound to a previous, closed loop.
2797
_instances: weakref.WeakKeyDictionary[
2898
asyncio.AbstractEventLoop,
29-
Dict[TransportKey, "AsyncTransportWithLogger"],
99+
Dict[TransportKey, "AsyncEnvdTransportWithLogger"],
30100
] = weakref.WeakKeyDictionary()
31101

32102
@property
33103
def pool(self):
34104
return self._pool
35105

36106

37-
def _get_cached_transport(cls, config: ConnectionConfig, http2: bool):
107+
def get_envd_transport(
108+
config: ConnectionConfig, http2: bool = True
109+
) -> AsyncEnvdTransportWithLogger:
38110
loop = asyncio.get_running_loop()
39-
loop_instances = cls._instances.get(loop)
111+
loop_instances = AsyncEnvdTransportWithLogger._instances.get(loop)
40112
if loop_instances is None:
41113
loop_instances = {}
42-
cls._instances[loop] = loop_instances
114+
AsyncEnvdTransportWithLogger._instances[loop] = loop_instances
43115

44116
key: TransportKey = (http2, config.proxy)
45117
transport = loop_instances.get(key)
46118
if transport is None:
47-
transport = cls(
119+
transport = AsyncEnvdTransportWithLogger(
48120
limits=limits,
49121
proxy=config.proxy,
50122
http2=http2,
@@ -53,22 +125,3 @@ def _get_cached_transport(cls, config: ConnectionConfig, http2: bool):
53125
loop_instances[key] = transport
54126

55127
return transport
56-
57-
58-
def get_transport(
59-
config: ConnectionConfig, http2: bool = True
60-
) -> AsyncTransportWithLogger:
61-
return _get_cached_transport(AsyncTransportWithLogger, config, http2)
62-
63-
64-
class AsyncEnvdTransportWithLogger(AsyncTransportWithLogger):
65-
_instances: weakref.WeakKeyDictionary[
66-
asyncio.AbstractEventLoop,
67-
Dict[TransportKey, "AsyncEnvdTransportWithLogger"],
68-
] = weakref.WeakKeyDictionary()
69-
70-
71-
def get_envd_transport(
72-
config: ConnectionConfig, http2: bool = True
73-
) -> AsyncEnvdTransportWithLogger:
74-
return _get_cached_transport(AsyncEnvdTransportWithLogger, config, http2)

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

Lines changed: 72 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
1-
from typing import Dict, Optional, Tuple
1+
from typing import Dict, Optional, Tuple, Union
22

33
import httpx
44
import threading
55

66
from httpx._types import ProxyTypes
7-
8-
from e2b.api import ApiClient, connection_retries, limits
7+
from pyqwest import SyncHTTPTransport, SyncRequest, SyncResponse
8+
from pyqwest.httpx import PyqwestTransport
9+
from pyqwest.middleware.retry import SyncRetryTransport
10+
11+
from e2b.api import (
12+
ApiClient,
13+
connection_retries,
14+
limits,
15+
pool_idle_timeout,
16+
pool_max_idle_per_host,
17+
proxy_to_url,
18+
)
919
from e2b.connection_config import ConnectionConfig
1020

1121
TransportKey = Tuple[bool, Optional[ProxyTypes]]
@@ -19,38 +29,72 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient:
1929
)
2030

2131

22-
class TransportWithLogger(httpx.HTTPTransport):
32+
class ApiPyqwestTransport(PyqwestTransport):
33+
"""Strip the ``Host`` header httpx adds to every request: hyper derives
34+
HTTP/1 ``Host`` and HTTP/2 ``:authority`` from the URL itself, and
35+
forwarding an explicit ``host`` header on an HTTP/2 connection makes the
36+
E2B API edge reset the stream with PROTOCOL_ERROR. (Custom ``Host``
37+
overrides are therefore not honored, matching hyper's URL-derived
38+
behavior.)"""
39+
40+
def handle_request(self, request: httpx.Request) -> httpx.Response:
41+
if "host" in request.headers:
42+
del request.headers["host"]
43+
return super().handle_request(request)
44+
45+
46+
class ConnectionRetryTransport(SyncRetryTransport):
47+
"""Retry only failures establishing the connection, matching the
48+
connect-only ``retries`` of the httpx transport this replaced: pyqwest
49+
raises the builtin ``ConnectionError`` only before the request was
50+
written, so these retries can never replay a request the API may have
51+
received. The retry middleware's default policy would otherwise also
52+
retry I/O errors and 429/5xx responses for idempotent methods."""
53+
54+
def should_retry_response(
55+
self, request: SyncRequest, response: Union[SyncResponse, Exception]
56+
) -> bool:
57+
return isinstance(response, ConnectionError)
58+
59+
60+
_transport_lock = threading.Lock()
61+
# One transport (= one connection pool) per proxy; None is the direct pool.
62+
# pyqwest transports are thread-safe, so unlike the httpx envd transports
63+
# below, the cache is process-global rather than per-thread.
64+
_transports: Dict[Optional[str], "ApiPyqwestTransport"] = {}
65+
66+
67+
def get_transport(config: ConnectionConfig) -> "ApiPyqwestTransport":
68+
"""The shared pyqwest-backed httpx transport for REST API calls. For TLS
69+
connections ALPN negotiates the HTTP version (HTTP/2 against the E2B
70+
API), like the http2-enabled httpx transport this replaced."""
71+
proxy_url = proxy_to_url(config.proxy)
72+
with _transport_lock:
73+
transport = _transports.get(proxy_url)
74+
if transport is None:
75+
transport = ApiPyqwestTransport(
76+
ConnectionRetryTransport(
77+
SyncHTTPTransport(
78+
tls_include_system_certs=True,
79+
proxy=proxy_url,
80+
pool_idle_timeout=pool_idle_timeout,
81+
pool_max_idle_per_host=pool_max_idle_per_host,
82+
),
83+
max_retries=connection_retries,
84+
)
85+
)
86+
_transports[proxy_url] = transport
87+
return transport
88+
89+
90+
class EnvdTransportWithLogger(httpx.HTTPTransport):
2391
_thread_local = threading.local()
2492

2593
@property
2694
def pool(self):
2795
return self._pool
2896

2997

30-
def get_transport(config: ConnectionConfig, http2: bool = True) -> TransportWithLogger:
31-
instances: Dict[TransportKey, TransportWithLogger] = getattr(
32-
TransportWithLogger._thread_local, "instances", {}
33-
)
34-
key: TransportKey = (http2, config.proxy)
35-
cached = instances.get(key)
36-
if cached is not None:
37-
return cached
38-
39-
transport = TransportWithLogger(
40-
limits=limits,
41-
proxy=config.proxy,
42-
http2=http2,
43-
retries=connection_retries,
44-
)
45-
instances[key] = transport
46-
TransportWithLogger._thread_local.instances = instances
47-
return transport
48-
49-
50-
class EnvdTransportWithLogger(TransportWithLogger):
51-
_thread_local = threading.local()
52-
53-
5498
def get_envd_transport(
5599
config: ConnectionConfig, http2: bool = True
56100
) -> EnvdTransportWithLogger:

packages/python-sdk/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ dependencies = [
1818
"typing-extensions>=4.1.0",
1919
"dockerfile-parse>=2.0.1,<3",
2020
"rich>=14.0.0",
21+
"pyqwest>=0.7.0,<0.8",
2122
]
2223

2324
[project.urls]

0 commit comments

Comments
 (0)