Skip to content

Commit c3a562c

Browse files
mishushakovclaude
andcommitted
refactor(python-sdk): share the pyqwest transport plumbing with the envd RPC stack
With the envd RPC migration (#1558) on main, both stacks carried identical copies of the transport pieces. Make e2b.api the canonical home: proxy_to_url (with stack-neutral error messages) and the pool tuning move out of e2b.envd.client_shared, and the flavor modules gain a shared retrying_http_transport factory + ConnectionRetryTransport that e2b.envd.client_sync/client_async now import instead of defining their own. The stacks keep separate per-proxy pools (unification is a follow-up). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fa0a167 commit c3a562c

11 files changed

Lines changed: 195 additions & 335 deletions

File tree

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

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
import httpx
1010
from httpx import AsyncBaseTransport, BaseTransport, Limits, Timeout
11-
from httpx._types import ProxyTypes
1211
from pyqwest import Proxy
1312

1413
from e2b.api.client.client import AuthenticatedClient
@@ -70,11 +69,11 @@ async def on_response(response: Response) -> None:
7069

7170
connection_retries = int(os.getenv("E2B_CONNECTION_RETRIES") or "3")
7271

73-
# Mirror the httpx pool tuning above with pyqwest's equivalents for the REST
74-
# API transports. `pool_max_idle_per_host` is per host rather than httpx's
75-
# global idle cap, but API traffic goes to a single host, so the values map
76-
# directly; reqwest has no counterpart to `E2B_MAX_CONNECTIONS` (it does not
77-
# cap concurrent connections).
72+
# Mirror the httpx pool tuning above with pyqwest's equivalents, shared by
73+
# the REST API and envd RPC transports. `pool_max_idle_per_host` is per host
74+
# rather than httpx's global idle cap, which suits both: API traffic goes to
75+
# a single host and each sandbox is its own host. reqwest has no counterpart
76+
# to `E2B_MAX_CONNECTIONS` (it does not cap concurrent connections).
7877
pool_idle_timeout = float(os.getenv("E2B_KEEPALIVE_EXPIRY") or "300")
7978
pool_max_idle_per_host = int(os.getenv("E2B_MAX_KEEPALIVE_CONNECTIONS") or "20")
8079

@@ -95,7 +94,7 @@ def to_pyqwest(self) -> Proxy:
9594
return Proxy(self.url, auth=self.auth, headers=self.headers or None)
9695

9796

98-
def proxy_to_config(proxy: Optional[ProxyTypes]) -> Optional[ProxyConfig]:
97+
def proxy_to_config(proxy: object) -> Optional[ProxyConfig]:
9998
"""Convert the ``proxy`` connection option — a URL string, an
10099
``httpx.URL``, or an ``httpx.Proxy`` — to the proxy configuration pyqwest
101100
transports take: a proxy URL (scheme http, https, socks5, or socks5h,
@@ -110,9 +109,7 @@ def proxy_to_config(proxy: Optional[ProxyTypes]) -> Optional[ProxyConfig]:
110109
return ProxyConfig(str(proxy))
111110
if isinstance(proxy, httpx.Proxy):
112111
if proxy.ssl_context is not None:
113-
raise InvalidArgumentException(
114-
"E2B API calls don't support httpx.Proxy ssl_context"
115-
)
112+
raise InvalidArgumentException("httpx.Proxy ssl_context is not supported")
116113
# httpx.Proxy splits userinfo out of the URL into `.auth`; pyqwest
117114
# takes the credentials the same way, so they pass straight through.
118115
return ProxyConfig(
@@ -121,8 +118,8 @@ def proxy_to_config(proxy: Optional[ProxyTypes]) -> Optional[ProxyConfig]:
121118
headers=tuple(proxy.headers.items()),
122119
)
123120
raise InvalidArgumentException(
124-
"E2B API calls support only URL-string, httpx.URL, and httpx.Proxy "
125-
'proxies, e.g. proxy="http://user:pass@localhost:8030"'
121+
"Only URL-string, httpx.URL, and httpx.Proxy proxies are supported, "
122+
'e.g. proxy="http://user:pass@localhost:8030"'
126123
)
127124

128125

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

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -54,19 +54,44 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
5454

5555

5656
class ConnectionRetryTransport(RetryTransport):
57-
"""Retry only failures establishing the connection, matching the
58-
connect-only ``retries`` of the httpx transport this replaced: pyqwest
59-
raises the builtin ``ConnectionError`` only before the request was
60-
written, so these retries can never replay a request the API may have
61-
received. The retry middleware's default policy would otherwise also
62-
retry I/O errors and 429/5xx responses for idempotent methods."""
57+
"""Retry only failures establishing the connection — shared by the REST
58+
API and envd RPC stacks: pyqwest raises the builtin ``ConnectionError``
59+
only before the request was written, so these retries can never replay a
60+
request the server may have received (a delivered REST call or unary RPC
61+
like ``SendInput``). This matches the connect-only ``retries`` of the
62+
httpx transports this replaced; the retry middleware's default policy
63+
would otherwise also retry I/O errors and 429/5xx responses for
64+
idempotent methods."""
6365

6466
def should_retry_response(
6567
self, request: Request, response: Union[Response, Exception]
6668
) -> bool:
6769
return isinstance(response, ConnectionError)
6870

6971

72+
def retrying_http_transport(
73+
proxy: Optional[ProxyConfig],
74+
) -> ConnectionRetryTransport:
75+
"""A fresh pyqwest transport (= its own connection pool) with the SDK's
76+
shared tuning — system CA certs (without which TLS through an
77+
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).
80+
Requests are logged by pyqwest itself on the ``pyqwest.access`` and
81+
``pyqwest`` loggers at ``DEBUG`` (off unless enabled) — the transport-level
82+
diagnostics httpcore used to provide. The SDK's own ``logger`` option is
83+
separate and sits above this, on the httpx client."""
84+
return ConnectionRetryTransport(
85+
HTTPTransport(
86+
tls_include_system_certs=True,
87+
proxy=proxy.to_pyqwest() if proxy is not None else None,
88+
pool_idle_timeout=pool_idle_timeout,
89+
pool_max_idle_per_host=pool_max_idle_per_host,
90+
),
91+
max_retries=connection_retries,
92+
)
93+
94+
7095
_transport_lock = threading.Lock()
7196
# One transport (= one connection pool) per proxy; None is the direct pool.
7297
# pyqwest's I/O runs on its own Rust runtime, so unlike the httpx envd
@@ -78,27 +103,12 @@ def should_retry_response(
78103
def get_transport(config: ConnectionConfig) -> "AsyncApiPyqwestTransport":
79104
"""The shared pyqwest-backed httpx transport for REST API calls. For TLS
80105
connections ALPN negotiates the HTTP version (HTTP/2 against the E2B
81-
API), like the http2-enabled httpx transport this replaced.
82-
83-
Requests are logged by pyqwest itself on the ``pyqwest.access`` and
84-
``pyqwest`` loggers at ``DEBUG`` (off unless enabled) — the transport-level
85-
diagnostics httpcore used to provide. The SDK's own ``logger`` option is
86-
separate and sits above this, on the httpx client."""
106+
API), like the http2-enabled httpx transport this replaced."""
87107
proxy = proxy_to_config(config.proxy)
88108
with _transport_lock:
89109
transport = _transports.get(proxy)
90110
if transport is None:
91-
transport = AsyncApiPyqwestTransport(
92-
ConnectionRetryTransport(
93-
HTTPTransport(
94-
tls_include_system_certs=True,
95-
proxy=proxy.to_pyqwest() if proxy is not None else None,
96-
pool_idle_timeout=pool_idle_timeout,
97-
pool_max_idle_per_host=pool_max_idle_per_host,
98-
),
99-
max_retries=connection_retries,
100-
)
101-
)
111+
transport = AsyncApiPyqwestTransport(retrying_http_transport(proxy))
102112
_transports[proxy] = transport
103113
return transport
104114

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

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -50,19 +50,44 @@ def handle_request(self, request: httpx.Request) -> httpx.Response:
5050

5151

5252
class ConnectionRetryTransport(SyncRetryTransport):
53-
"""Retry only failures establishing the connection, matching the
54-
connect-only ``retries`` of the httpx transport this replaced: pyqwest
55-
raises the builtin ``ConnectionError`` only before the request was
56-
written, so these retries can never replay a request the API may have
57-
received. The retry middleware's default policy would otherwise also
58-
retry I/O errors and 429/5xx responses for idempotent methods."""
53+
"""Retry only failures establishing the connection — shared by the REST
54+
API and envd RPC stacks: pyqwest raises the builtin ``ConnectionError``
55+
only before the request was written, so these retries can never replay a
56+
request the server may have received (a delivered REST call or unary RPC
57+
like ``SendInput``). This matches the connect-only ``retries`` of the
58+
httpx transports this replaced; the retry middleware's default policy
59+
would otherwise also retry I/O errors and 429/5xx responses for
60+
idempotent methods."""
5961

6062
def should_retry_response(
6163
self, request: SyncRequest, response: Union[SyncResponse, Exception]
6264
) -> bool:
6365
return isinstance(response, ConnectionError)
6466

6567

68+
def retrying_http_transport(
69+
proxy: Optional[ProxyConfig],
70+
) -> ConnectionRetryTransport:
71+
"""A fresh pyqwest transport (= its own connection pool) with the SDK's
72+
shared tuning — system CA certs (without which TLS through an
73+
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).
76+
Requests are logged by pyqwest itself on the ``pyqwest.access`` and
77+
``pyqwest`` loggers at ``DEBUG`` (off unless enabled) — the transport-level
78+
diagnostics httpcore used to provide. The SDK's own ``logger`` option is
79+
separate and sits above this, on the httpx client."""
80+
return ConnectionRetryTransport(
81+
SyncHTTPTransport(
82+
tls_include_system_certs=True,
83+
proxy=proxy.to_pyqwest() if proxy is not None else None,
84+
pool_idle_timeout=pool_idle_timeout,
85+
pool_max_idle_per_host=pool_max_idle_per_host,
86+
),
87+
max_retries=connection_retries,
88+
)
89+
90+
6691
_transport_lock = threading.Lock()
6792
# One transport (= one connection pool) per proxy; None is the direct pool.
6893
# pyqwest transports are thread-safe, so unlike the httpx envd transports
@@ -73,27 +98,12 @@ def should_retry_response(
7398
def get_transport(config: ConnectionConfig) -> "ApiPyqwestTransport":
7499
"""The shared pyqwest-backed httpx transport for REST API calls. For TLS
75100
connections ALPN negotiates the HTTP version (HTTP/2 against the E2B
76-
API), like the http2-enabled httpx transport this replaced.
77-
78-
Requests are logged by pyqwest itself on the ``pyqwest.access`` and
79-
``pyqwest`` loggers at ``DEBUG`` (off unless enabled) — the transport-level
80-
diagnostics httpcore used to provide. The SDK's own ``logger`` option is
81-
separate and sits above this, on the httpx client."""
101+
API), like the http2-enabled httpx transport this replaced."""
82102
proxy = proxy_to_config(config.proxy)
83103
with _transport_lock:
84104
transport = _transports.get(proxy)
85105
if transport is None:
86-
transport = ApiPyqwestTransport(
87-
ConnectionRetryTransport(
88-
SyncHTTPTransport(
89-
tls_include_system_certs=True,
90-
proxy=proxy.to_pyqwest() if proxy is not None else None,
91-
pool_idle_timeout=pool_idle_timeout,
92-
pool_max_idle_per_host=pool_max_idle_per_host,
93-
),
94-
max_retries=connection_retries,
95-
)
96-
)
106+
transport = ApiPyqwestTransport(retrying_http_transport(proxy))
97107
_transports[proxy] = transport
98108
return transport
99109

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

Lines changed: 12 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,20 @@
99
Callable,
1010
Optional,
1111
TypeVar,
12-
Union,
1312
cast,
1413
)
1514

1615
from connectrpc.code import Code
1716
from connectrpc.errors import ConnectError
18-
from pyqwest import Client, HTTPTransport, Request, Response, Transport
19-
from pyqwest.middleware.retry import RetryTransport
17+
from pyqwest import Client, Request, Response, Transport
2018

21-
from e2b.api import connection_retries
19+
from e2b.api import ProxyConfig, proxy_to_config
20+
from e2b.api.client_async import retrying_http_transport
2221
from e2b.connection_config import ConnectionConfig
2322
from e2b.envd.client_shared import (
2423
ENVD_JSON_CODEC,
2524
ENVD_RPC_COMPRESSION,
2625
plain_http_error,
27-
pool_idle_timeout,
28-
pool_max_idle_per_host,
29-
proxy_to_url,
30-
should_retry_connection,
3126
)
3227
from e2b.envd.interceptors import build_interceptors
3328
from e2b.exceptions import TimeoutException
@@ -37,7 +32,7 @@
3732

3833
_transport_lock = threading.Lock()
3934
# One transport (= one connection pool) per proxy; None is the direct pool.
40-
_transports: dict[Optional[str], "PlainHTTPErrorTransport"] = {}
35+
_transports: dict[Optional[ProxyConfig], "PlainHTTPErrorTransport"] = {}
4136

4237

4338
class PlainHTTPErrorTransport:
@@ -70,37 +65,16 @@ async def execute(self, request: Request) -> Response:
7065
raise error
7166

7267

73-
class ConnectionRetryTransport(RetryTransport):
74-
"""Retry only failures establishing the connection; see
75-
:func:`e2b.envd.client_shared.should_retry_connection` for the policy
76-
rationale."""
77-
78-
def should_retry_response(
79-
self, request: Request, response: Union[Response, Exception]
80-
) -> bool:
81-
return should_retry_connection(response)
82-
83-
84-
def get_transport(proxy_url: Optional[str]) -> "PlainHTTPErrorTransport":
68+
def get_transport(proxy: Optional[ProxyConfig]) -> "PlainHTTPErrorTransport":
8569
with _transport_lock:
86-
transport = _transports.get(proxy_url)
70+
transport = _transports.get(proxy)
8771
if transport is None:
8872
# connectrpc arms the per-call deadline around the transport, so
8973
# retry backoff counts against the request timeout. The plain-
9074
# error normalization sits outside the retries so it converts
9175
# the settled response once.
92-
transport = PlainHTTPErrorTransport(
93-
ConnectionRetryTransport(
94-
HTTPTransport(
95-
tls_include_system_certs=True,
96-
proxy=proxy_url,
97-
pool_idle_timeout=pool_idle_timeout,
98-
pool_max_idle_per_host=pool_max_idle_per_host,
99-
),
100-
max_retries=connection_retries,
101-
)
102-
)
103-
_transports[proxy_url] = transport
76+
transport = PlainHTTPErrorTransport(retrying_http_transport(proxy))
77+
_transports[proxy] = transport
10478
return transport
10579

10680

@@ -111,11 +85,11 @@ def create_rpc_client(
11185
) -> TClient:
11286
"""Build a generated async connectrpc client (e.g. ``ProcessClient``)
11387
wired with the shared pyqwest transport (which retries failed connects,
114-
see :class:`ConnectionRetryTransport`), the envd JSON codec, and the
115-
SDK's default-header and logging interceptors. Compression is disabled
116-
(see ``ENVD_RPC_COMPRESSION``).
88+
see :class:`e2b.api.client_async.ConnectionRetryTransport`), the envd
89+
JSON codec, and the SDK's default-header and logging interceptors.
90+
Compression is disabled (see ``ENVD_RPC_COMPRESSION``).
11791
"""
118-
http_client = Client(get_transport(proxy_to_url(config.proxy)))
92+
http_client = Client(get_transport(proxy_to_config(config.proxy)))
11993
return client_cls(
12094
base_url,
12195
codec=ENVD_JSON_CODEC,

0 commit comments

Comments
 (0)