Skip to content

Commit 532ac7a

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

11 files changed

Lines changed: 104 additions & 266 deletions

File tree

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

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

99
import httpx
1010
from httpx import AsyncBaseTransport, BaseTransport, Limits, Timeout
11-
from httpx._types import ProxyTypes
12-
1311
from e2b.api.client.client import AuthenticatedClient
1412
from e2b.api.client.types import Response
1513
from e2b.api.metadata import default_headers
@@ -69,16 +67,16 @@ async def on_response(response: Response) -> None:
6967

7068
connection_retries = int(os.getenv("E2B_CONNECTION_RETRIES") or "3")
7169

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).
70+
# Mirror the httpx pool tuning above with pyqwest's equivalents, shared by
71+
# the REST API and envd RPC transports. `pool_max_idle_per_host` is per host
72+
# rather than httpx's global idle cap, which suits both: API traffic goes to
73+
# a single host and each sandbox is its own host. reqwest has no counterpart
74+
# to `E2B_MAX_CONNECTIONS` (it does not cap concurrent connections).
7775
pool_idle_timeout = float(os.getenv("E2B_KEEPALIVE_EXPIRY") or "300")
7876
pool_max_idle_per_host = int(os.getenv("E2B_MAX_KEEPALIVE_CONNECTIONS") or "20")
7977

8078

81-
def proxy_to_url(proxy: Optional[ProxyTypes]) -> Optional[str]:
79+
def proxy_to_url(proxy: object) -> Optional[str]:
8280
"""Narrow the ``proxy`` connection option to the proxy URL string pyqwest
8381
transports take (scheme http, https, socks5, or socks5h, credentials in
8482
the URL userinfo). ``httpx.URL`` and ``httpx.Proxy`` — which the httpx
@@ -94,20 +92,18 @@ def proxy_to_url(proxy: Optional[ProxyTypes]) -> Optional[str]:
9492
if isinstance(proxy, httpx.Proxy):
9593
if proxy.headers:
9694
raise InvalidArgumentException(
97-
"E2B API calls don't support httpx.Proxy custom headers; "
95+
"httpx.Proxy custom headers are not supported; "
9896
"pass credentials in the proxy URL instead, "
9997
'e.g. proxy="http://user:pass@localhost:8030"'
10098
)
10199
if proxy.ssl_context is not None:
102-
raise InvalidArgumentException(
103-
"E2B API calls don't support httpx.Proxy ssl_context"
104-
)
100+
raise InvalidArgumentException("httpx.Proxy ssl_context is not supported")
105101
url = proxy.url
106102
if proxy.auth is not None:
107103
url = url.copy_with(username=proxy.auth[0], password=proxy.auth[1])
108104
return str(url)
109105
raise InvalidArgumentException(
110-
"E2B API calls support only URL-string proxies, "
106+
"Only URL-string proxies are supported, "
111107
'e.g. proxy="http://user:pass@localhost:8030"'
112108
)
113109

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

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -53,19 +53,38 @@ 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(proxy_url: Optional[str]) -> ConnectionRetryTransport:
72+
"""A fresh pyqwest transport (= its own connection pool) with the SDK's
73+
shared tuning — system CA certs (without which TLS through an
74+
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)."""
77+
return ConnectionRetryTransport(
78+
HTTPTransport(
79+
tls_include_system_certs=True,
80+
proxy=proxy_url,
81+
pool_idle_timeout=pool_idle_timeout,
82+
pool_max_idle_per_host=pool_max_idle_per_host,
83+
),
84+
max_retries=connection_retries,
85+
)
86+
87+
6988
_transport_lock = threading.Lock()
7089
# One transport (= one connection pool) per proxy; None is the direct pool.
7190
# pyqwest's I/O runs on its own Rust runtime, so unlike the httpx envd
@@ -82,17 +101,7 @@ def get_transport(config: ConnectionConfig) -> "AsyncApiPyqwestTransport":
82101
with _transport_lock:
83102
transport = _transports.get(proxy_url)
84103
if transport is None:
85-
transport = AsyncApiPyqwestTransport(
86-
ConnectionRetryTransport(
87-
HTTPTransport(
88-
tls_include_system_certs=True,
89-
proxy=proxy_url,
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-
)
104+
transport = AsyncApiPyqwestTransport(retrying_http_transport(proxy_url))
96105
_transports[proxy_url] = transport
97106
return transport
98107

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

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -49,19 +49,38 @@ 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(proxy_url: Optional[str]) -> ConnectionRetryTransport:
68+
"""A fresh pyqwest transport (= its own connection pool) with the SDK's
69+
shared tuning — system CA certs (without which TLS through an
70+
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)."""
73+
return ConnectionRetryTransport(
74+
SyncHTTPTransport(
75+
tls_include_system_certs=True,
76+
proxy=proxy_url,
77+
pool_idle_timeout=pool_idle_timeout,
78+
pool_max_idle_per_host=pool_max_idle_per_host,
79+
),
80+
max_retries=connection_retries,
81+
)
82+
83+
6584
_transport_lock = threading.Lock()
6685
# One transport (= one connection pool) per proxy; None is the direct pool.
6786
# pyqwest transports are thread-safe, so unlike the httpx envd transports
@@ -77,17 +96,7 @@ def get_transport(config: ConnectionConfig) -> "ApiPyqwestTransport":
7796
with _transport_lock:
7897
transport = _transports.get(proxy_url)
7998
if transport is None:
80-
transport = ApiPyqwestTransport(
81-
ConnectionRetryTransport(
82-
SyncHTTPTransport(
83-
tls_include_system_certs=True,
84-
proxy=proxy_url,
85-
pool_idle_timeout=pool_idle_timeout,
86-
pool_max_idle_per_host=pool_max_idle_per_host,
87-
),
88-
max_retries=connection_retries,
89-
)
90-
)
99+
transport = ApiPyqwestTransport(retrying_http_transport(proxy_url))
91100
_transports[proxy_url] = transport
92101
return transport
93102

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

Lines changed: 7 additions & 33 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 proxy_to_url
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
@@ -70,17 +65,6 @@ 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-
8468
def get_transport(proxy_url: Optional[str]) -> "PlainHTTPErrorTransport":
8569
with _transport_lock:
8670
transport = _transports.get(proxy_url)
@@ -89,17 +73,7 @@ def get_transport(proxy_url: Optional[str]) -> "PlainHTTPErrorTransport":
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-
)
76+
transport = PlainHTTPErrorTransport(retrying_http_transport(proxy_url))
10377
_transports[proxy_url] = transport
10478
return transport
10579

@@ -111,9 +85,9 @@ 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
"""
11892
http_client = Client(get_transport(proxy_to_url(config.proxy)))
11993
return client_cls(

packages/python-sdk/e2b/envd/client_shared.py

Lines changed: 6 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""envd RPC client plumbing shared by the sync and async flavors.
22
33
The envd RPC clients (process, filesystem) run on `connectrpc`, whose HTTP
4-
layer is `pyqwest` (Rust reqwest/hyper). This is a separate stack from the
5-
`httpx` transports in `e2b.api`, which keep serving the REST API and the
6-
multipart file transfer endpoints. Unlike the previous httpcore-based
7-
transport, hyper sends RST_STREAM when a server stream is closed early, so
8-
abandoned command/watch streams don't leak on the shared HTTP/2 connection.
4+
layer is `pyqwest` (Rust reqwest/hyper) — built on the same
5+
`retrying_http_transport` pieces as the REST API client in `e2b.api`, in a
6+
separately cached pool. Only the multipart file transfer endpoints stay on
7+
the `httpx` envd transports. Unlike the previous httpcore-based transport,
8+
hyper sends RST_STREAM when a server stream is closed early, so abandoned
9+
command/watch streams don't leak on the shared HTTP/2 connection.
910
1011
The flavor-specific transports and client factories live in
1112
:mod:`e2b.envd.client_sync` and :mod:`e2b.envd.client_async`, mirroring the
@@ -15,22 +16,12 @@
1516
"""
1617

1718
import json
18-
import os
1919
from typing import Optional, TypedDict, TypeVar
2020

21-
import httpx
2221
from connectrpc.code import Code
2322
from connectrpc.errors import ConnectError
2423
from protobuf import Message
2524

26-
from e2b.exceptions import InvalidArgumentException
27-
28-
# Mirror the httpx pool tuning in `e2b.api.limits` with pyqwest's equivalents.
29-
# `pool_max_idle_per_host` is per host rather than httpx's global idle cap,
30-
# which suits envd traffic — each sandbox is its own host.
31-
pool_idle_timeout = float(os.getenv("E2B_KEEPALIVE_EXPIRY") or "300")
32-
pool_max_idle_per_host = int(os.getenv("E2B_MAX_KEEPALIVE_CONNECTIONS") or "20")
33-
3425
_MESSAGE = TypeVar("_MESSAGE", bound=Message)
3526

3627

@@ -133,25 +124,6 @@ def plain_http_error(
133124
)
134125

135126

136-
def should_retry_connection(response: object) -> bool:
137-
"""Whether a transport result is a retryable connection-establishment
138-
failure — the shared policy of the flavor ``ConnectionRetryTransport``s.
139-
140-
pyqwest raises the builtin ``ConnectionError`` only before the request
141-
was written, so retrying exactly these failures can never replay a
142-
request envd may have received — which could re-run a command or
143-
re-deliver events — for unary and streaming RPCs alike. Anything later
144-
(``WriteError``/``ReadError``/``StreamError``, error responses) surfaces
145-
to the caller; the retry middleware's default policy would otherwise also
146-
retry I/O errors and 429/5xx responses for idempotent methods. This
147-
replaces httpcore's transport ``retries`` from the previous stack and
148-
deliberately drops the vendored client's retry on connections dropped
149-
mid-request, which could re-execute a delivered unary RPC like
150-
``SendInput``.
151-
"""
152-
return isinstance(response, ConnectionError)
153-
154-
155127
class _RPCCompression(TypedDict):
156128
send_compression: None
157129
accept_compression: "tuple[()]"
@@ -168,38 +140,3 @@ class _RPCCompression(TypedDict):
168140
"send_compression": None,
169141
"accept_compression": (),
170142
}
171-
172-
173-
def proxy_to_url(proxy: object) -> Optional[str]:
174-
"""Narrow the ``proxy`` connection option to the proxy URL string pyqwest
175-
transports take (scheme http, https, socks5, or socks5h, credentials in
176-
the URL userinfo). ``httpx.URL`` and ``httpx.Proxy`` — which the REST
177-
client accepts and the vendored envd client used to — are converted when
178-
they reduce to such a URL; ``httpx.Proxy`` extras that don't (custom
179-
headers, an ssl_context) are rejected rather than silently dropped.
180-
"""
181-
if proxy is None:
182-
return None
183-
if isinstance(proxy, str):
184-
return proxy
185-
if isinstance(proxy, httpx.URL):
186-
return str(proxy)
187-
if isinstance(proxy, httpx.Proxy):
188-
if proxy.headers:
189-
raise InvalidArgumentException(
190-
"Sandbox RPC calls don't support httpx.Proxy custom headers; "
191-
"pass credentials in the proxy URL instead, "
192-
'e.g. proxy="http://user:pass@localhost:8030"'
193-
)
194-
if proxy.ssl_context is not None:
195-
raise InvalidArgumentException(
196-
"Sandbox RPC calls don't support httpx.Proxy ssl_context"
197-
)
198-
url = proxy.url
199-
if proxy.auth is not None:
200-
url = url.copy_with(username=proxy.auth[0], password=proxy.auth[1])
201-
return str(url)
202-
raise InvalidArgumentException(
203-
"Sandbox RPC calls support only URL-string proxies, "
204-
'e.g. proxy="http://user:pass@localhost:8030"'
205-
)

0 commit comments

Comments
 (0)