Skip to content

Commit cdd5314

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 ccab1da commit cdd5314

11 files changed

Lines changed: 195 additions & 334 deletions

File tree

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

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,11 @@ async def on_response(response: Response) -> None:
6969

7070
connection_retries = int(os.getenv("E2B_CONNECTION_RETRIES") or "3")
7171

72-
# Mirror the httpx pool tuning above with pyqwest's equivalents for the REST
73-
# API transports. `pool_max_idle_per_host` is per host rather than httpx's
74-
# global idle cap, but API traffic goes to a single host, so the values map
75-
# directly; reqwest has no counterpart to `E2B_MAX_CONNECTIONS` (it does not
76-
# 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).
7777
pool_idle_timeout = float(os.getenv("E2B_KEEPALIVE_EXPIRY") or "300")
7878
pool_max_idle_per_host = int(os.getenv("E2B_MAX_KEEPALIVE_CONNECTIONS") or "20")
7979

@@ -109,9 +109,7 @@ def proxy_to_config(proxy: Optional[ProxyTypes]) -> Optional[ProxyConfig]:
109109
return ProxyConfig(str(proxy))
110110
if isinstance(proxy, httpx.Proxy):
111111
if proxy.ssl_context is not None:
112-
raise InvalidArgumentException(
113-
"E2B API calls don't support httpx.Proxy ssl_context"
114-
)
112+
raise InvalidArgumentException("httpx.Proxy ssl_context is not supported")
115113
# httpx.Proxy splits userinfo out of the URL into `.auth`; pyqwest
116114
# takes the credentials the same way, so they pass straight through.
117115
return ProxyConfig(
@@ -120,8 +118,8 @@ def proxy_to_config(proxy: Optional[ProxyTypes]) -> Optional[ProxyConfig]:
120118
headers=tuple(proxy.headers.items()),
121119
)
122120
raise InvalidArgumentException(
123-
"E2B API calls support only URL-string, httpx.URL, and httpx.Proxy "
124-
'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"'
125123
)
126124

127125

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

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

5454

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

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

6870

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

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

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

5050

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

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

6466

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

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)