Skip to content

Commit 4052953

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 e78368f commit 4052953

12 files changed

Lines changed: 108 additions & 268 deletions

File tree

.changeset/python-pyqwest-rest-transport.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,7 @@ such a URL (`httpx.Proxy` auth is folded back into the URL userinfo);
2626
`ssl_context` — raise `InvalidArgumentException` rather than being silently
2727
dropped.
2828

29-
envd traffic (sandbox commands, filesystem, PTY, file transfers) is not
30-
affected and stays on its existing stack.
29+
envd RPC (sandbox commands, filesystem watch, PTY) already runs on pyqwest
30+
via `connectrpc`; both stacks now share the same transport construction
31+
(TLS, pool tuning, connect-only retries) while keeping separate connection
32+
pools. envd file transfers stay on their httpx transports.

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
@@ -42,19 +42,38 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
4242

4343

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

5254
def should_retry_response(
5355
self, request: Request, response: Union[Response, Exception]
5456
) -> bool:
5557
return isinstance(response, ConnectionError)
5658

5759

60+
def retrying_http_transport(proxy_url: Optional[str]) -> ConnectionRetryTransport:
61+
"""A fresh pyqwest transport (= its own connection pool) with the SDK's
62+
shared tuning — system CA certs (without which TLS through an
63+
intercepting proxy fails), the httpx-equivalent pool limits, and
64+
connect-only retries. The REST API and envd RPC stacks each cache their
65+
own instances (pool unification is a follow-up)."""
66+
return ConnectionRetryTransport(
67+
HTTPTransport(
68+
tls_include_system_certs=True,
69+
proxy=proxy_url,
70+
pool_idle_timeout=pool_idle_timeout,
71+
pool_max_idle_per_host=pool_max_idle_per_host,
72+
),
73+
max_retries=connection_retries,
74+
)
75+
76+
5877
_transport_lock = threading.Lock()
5978
# One transport (= one connection pool) per proxy; None is the direct pool.
6079
# pyqwest's I/O runs on its own Rust runtime, so unlike the httpx envd
@@ -71,17 +90,7 @@ def get_transport(config: ConnectionConfig) -> "AsyncApiPyqwestTransport":
7190
with _transport_lock:
7291
transport = _transports.get(proxy_url)
7392
if transport is None:
74-
transport = AsyncApiPyqwestTransport(
75-
ConnectionRetryTransport(
76-
HTTPTransport(
77-
tls_include_system_certs=True,
78-
proxy=proxy_url,
79-
pool_idle_timeout=pool_idle_timeout,
80-
pool_max_idle_per_host=pool_max_idle_per_host,
81-
),
82-
max_retries=connection_retries,
83-
)
84-
)
93+
transport = AsyncApiPyqwestTransport(retrying_http_transport(proxy_url))
8594
_transports[proxy_url] = transport
8695
return transport
8796

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

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,38 @@ def handle_request(self, request: httpx.Request) -> httpx.Response:
4040

4141

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

5052
def should_retry_response(
5153
self, request: SyncRequest, response: Union[SyncResponse, Exception]
5254
) -> bool:
5355
return isinstance(response, ConnectionError)
5456

5557

58+
def retrying_http_transport(proxy_url: Optional[str]) -> ConnectionRetryTransport:
59+
"""A fresh pyqwest transport (= its own connection pool) with the SDK's
60+
shared tuning — system CA certs (without which TLS through an
61+
intercepting proxy fails), the httpx-equivalent pool limits, and
62+
connect-only retries. The REST API and envd RPC stacks each cache their
63+
own instances (pool unification is a follow-up)."""
64+
return ConnectionRetryTransport(
65+
SyncHTTPTransport(
66+
tls_include_system_certs=True,
67+
proxy=proxy_url,
68+
pool_idle_timeout=pool_idle_timeout,
69+
pool_max_idle_per_host=pool_max_idle_per_host,
70+
),
71+
max_retries=connection_retries,
72+
)
73+
74+
5675
_transport_lock = threading.Lock()
5776
# One transport (= one connection pool) per proxy; None is the direct pool.
5877
# pyqwest transports are thread-safe, so unlike the httpx envd transports
@@ -68,17 +87,7 @@ def get_transport(config: ConnectionConfig) -> "ApiPyqwestTransport":
6887
with _transport_lock:
6988
transport = _transports.get(proxy_url)
7089
if transport is None:
71-
transport = ApiPyqwestTransport(
72-
ConnectionRetryTransport(
73-
SyncHTTPTransport(
74-
tls_include_system_certs=True,
75-
proxy=proxy_url,
76-
pool_idle_timeout=pool_idle_timeout,
77-
pool_max_idle_per_host=pool_max_idle_per_host,
78-
),
79-
max_retries=connection_retries,
80-
)
81-
)
90+
transport = ApiPyqwestTransport(retrying_http_transport(proxy_url))
8291
_transports[proxy_url] = transport
8392
return transport
8493

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)