Skip to content

Commit fe6b157

Browse files
mishushakovclaude
andcommitted
feat(python-sdk): carry httpx.Proxy credentials and headers into pyqwest
Supersedes the earlier reduction of proxies to plain URL strings, now that pyqwest 0.8 has a Proxy object (curioswitch/pyqwest#194): `proxy_to_url` becomes `proxy_to_config`, returning the URL, credentials, and proxy headers as a tuple that both keys the transport caches and builds the `pyqwest.Proxy`. An `httpx.Proxy`'s custom headers are no longer rejected, and its credentials go out as `Proxy-Authorization` instead of being percent-encoded back into the URL userinfo. A per-proxy `ssl_context` still has no counterpart and is still rejected. The transport cache keys on the whole configuration, so the same proxy URL with different credentials or headers gets its own pool. `ProxyConfig` is a NamedTuple rather than a frozen dataclass because `tests/test_env_var_parsing.py` reloads `e2b.api`: a dataclass `__eq__` compares class identity, so keys built before and after a reload would silently stop matching. pyqwest 0.8 also logs requests itself on the `pyqwest.access` and `pyqwest` loggers at DEBUG, restoring the transport-level diagnostics httpcore used to provide; noted on `get_transport` and covered by a test, with the SDK's own `logger` option unchanged above it. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent f95aee3 commit fe6b157

6 files changed

Lines changed: 181 additions & 66 deletions

File tree

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

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,27 @@ Connection-establishment failures are retried with backoff
1919
the previous transports. Timeouts keep raising `httpx.ReadTimeout` (an
2020
`httpx.TimeoutException`), as before.
2121

22-
`proxy` for API calls now takes a URL string (e.g.
22+
`proxy` for API calls takes a URL string (e.g.
2323
`proxy="http://user:pass@localhost:8030"`, scheme http, https, socks5, or
24-
socks5h). `httpx.URL` and `httpx.Proxy` keep working when they reduce to
25-
such a URL (`httpx.Proxy` auth is folded back into the URL userinfo);
26-
`httpx.Proxy` extras pyqwest can't express — custom headers, an
27-
`ssl_context` — raise `InvalidArgumentException` rather than being silently
28-
dropped.
24+
socks5h), an `httpx.URL`, or an `httpx.Proxy` — including its credentials
25+
(sent as `Proxy-Authorization`) and any headers configured for the proxy. The
26+
one `httpx.Proxy` option pyqwest cannot express, a per-proxy `ssl_context`,
27+
raises `InvalidArgumentException` rather than being silently dropped.
28+
29+
Low-level HTTP logs stay available: where enabling the `httpcore` logger used
30+
to show connection-level detail, pyqwest logs one line per request on the
31+
`pyqwest.access` logger and request lifecycle records on `pyqwest`, both at
32+
`DEBUG` and off unless enabled:
33+
34+
```python
35+
import logging
36+
37+
logging.basicConfig()
38+
logging.getLogger("pyqwest.access").setLevel(logging.DEBUG)
39+
# DEBUG pyqwest.access - HTTP Request: POST https://api.e2b.app/sandboxes "HTTP/2 201 Created"
40+
```
41+
42+
The SDK's own `logger` option is unchanged and independent of these.
2943

3044
envd traffic is not affected: RPC (commands, PTY, filesystem watch) already
3145
runs on pyqwest via `connectrpc`, and the envd HTTP API (file transfers,

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

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
import re
55
from dataclasses import dataclass
66
from types import TracebackType
7-
from typing import Optional, Protocol, Union
7+
from typing import NamedTuple, Optional, Protocol, Tuple, Union
88

99
import httpx
1010
from httpx import AsyncBaseTransport, BaseTransport, Limits, Timeout
1111
from httpx._types import ProxyTypes
12+
from pyqwest import Proxy
1213

1314
from e2b.api.client.client import AuthenticatedClient
1415
from e2b.api.client.types import Response
@@ -78,37 +79,50 @@ async def on_response(response: Response) -> None:
7879
pool_max_idle_per_host = int(os.getenv("E2B_MAX_KEEPALIVE_CONNECTIONS") or "20")
7980

8081

81-
def proxy_to_url(proxy: Optional[ProxyTypes]) -> Optional[str]:
82-
"""Narrow the ``proxy`` connection option to the proxy URL string pyqwest
83-
transports take (scheme http, https, socks5, or socks5h, credentials in
84-
the URL userinfo). ``httpx.URL`` and ``httpx.Proxy`` — which the httpx
85-
transports accepted — are converted when they reduce to such a URL;
86-
``httpx.Proxy`` extras that don't (custom headers, an ssl_context) are
87-
rejected rather than silently dropped."""
82+
class ProxyConfig(NamedTuple):
83+
"""The ``proxy`` connection option in the shape pyqwest transports take.
84+
85+
A tuple so it can key the transport caches directly: it is hashable and
86+
compares by value, where a ``pyqwest.Proxy`` compares by identity and
87+
would hand every call its own connection pool."""
88+
89+
url: str
90+
auth: Optional[Tuple[str, str]] = None
91+
headers: Tuple[Tuple[str, str], ...] = ()
92+
93+
def to_pyqwest(self) -> Proxy:
94+
"""The ``pyqwest.Proxy`` to hand a transport."""
95+
return Proxy(self.url, auth=self.auth, headers=self.headers or None)
96+
97+
98+
def proxy_to_config(proxy: Optional[ProxyTypes]) -> Optional[ProxyConfig]:
99+
"""Convert the ``proxy`` connection option — a URL string, an
100+
``httpx.URL``, or an ``httpx.Proxy`` — to the proxy configuration pyqwest
101+
transports take: a proxy URL (scheme http, https, socks5, or socks5h,
102+
credentials allowed in the userinfo), basic-auth credentials, and headers
103+
to send to the proxy. An ``httpx.Proxy`` ``ssl_context`` has no pyqwest
104+
counterpart and is rejected rather than silently dropped."""
88105
if proxy is None:
89106
return None
90107
if isinstance(proxy, str):
91-
return proxy
108+
return ProxyConfig(proxy)
92109
if isinstance(proxy, httpx.URL):
93-
return str(proxy)
110+
return ProxyConfig(str(proxy))
94111
if isinstance(proxy, httpx.Proxy):
95-
if proxy.headers:
96-
raise InvalidArgumentException(
97-
"E2B API calls don't support httpx.Proxy custom headers; "
98-
"pass credentials in the proxy URL instead, "
99-
'e.g. proxy="http://user:pass@localhost:8030"'
100-
)
101112
if proxy.ssl_context is not None:
102113
raise InvalidArgumentException(
103114
"E2B API calls don't support httpx.Proxy ssl_context"
104115
)
105-
url = proxy.url
106-
if proxy.auth is not None:
107-
url = url.copy_with(username=proxy.auth[0], password=proxy.auth[1])
108-
return str(url)
116+
# httpx.Proxy splits userinfo out of the URL into `.auth`; pyqwest
117+
# takes the credentials the same way, so they pass straight through.
118+
return ProxyConfig(
119+
str(proxy.url),
120+
auth=proxy.auth,
121+
headers=tuple(proxy.headers.items()),
122+
)
109123
raise InvalidArgumentException(
110-
"E2B API calls support only URL-string proxies, "
111-
'e.g. proxy="http://user:pass@localhost:8030"'
124+
"E2B API calls support only URL-string, httpx.URL, and httpx.Proxy "
125+
'proxies, e.g. proxy="http://user:pass@localhost:8030"'
112126
)
113127

114128

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@
1212

1313
from e2b.api import (
1414
AsyncApiClient,
15+
ProxyConfig,
1516
connection_retries,
1617
limits,
1718
pool_idle_timeout,
1819
pool_max_idle_per_host,
19-
proxy_to_url,
20+
proxy_to_config,
2021
)
2122
from e2b.connection_config import ConnectionConfig
2223

@@ -71,29 +72,34 @@ def should_retry_response(
7172
# pyqwest's I/O runs on its own Rust runtime, so unlike the httpx envd
7273
# transports below, the transport is not bound to an event loop and the
7374
# cache is process-global rather than per-loop.
74-
_transports: Dict[Optional[str], "AsyncApiPyqwestTransport"] = {}
75+
_transports: Dict[Optional[ProxyConfig], "AsyncApiPyqwestTransport"] = {}
7576

7677

7778
def get_transport(config: ConnectionConfig) -> "AsyncApiPyqwestTransport":
7879
"""The shared pyqwest-backed httpx transport for REST API calls. For TLS
7980
connections ALPN negotiates the HTTP version (HTTP/2 against the E2B
80-
API), like the http2-enabled httpx transport this replaced."""
81-
proxy_url = proxy_to_url(config.proxy)
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."""
87+
proxy = proxy_to_config(config.proxy)
8288
with _transport_lock:
83-
transport = _transports.get(proxy_url)
89+
transport = _transports.get(proxy)
8490
if transport is None:
8591
transport = AsyncApiPyqwestTransport(
8692
ConnectionRetryTransport(
8793
HTTPTransport(
8894
tls_include_system_certs=True,
89-
proxy=proxy_url,
95+
proxy=proxy.to_pyqwest() if proxy is not None else None,
9096
pool_idle_timeout=pool_idle_timeout,
9197
pool_max_idle_per_host=pool_max_idle_per_host,
9298
),
9399
max_retries=connection_retries,
94100
)
95101
)
96-
_transports[proxy_url] = transport
102+
_transports[proxy] = transport
97103
return transport
98104

99105

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@
1010

1111
from e2b.api import (
1212
ApiClient,
13+
ProxyConfig,
1314
connection_retries,
1415
limits,
1516
pool_idle_timeout,
1617
pool_max_idle_per_host,
17-
proxy_to_url,
18+
proxy_to_config,
1819
)
1920
from e2b.connection_config import ConnectionConfig
2021

@@ -66,29 +67,34 @@ def should_retry_response(
6667
# One transport (= one connection pool) per proxy; None is the direct pool.
6768
# pyqwest transports are thread-safe, so unlike the httpx envd transports
6869
# below, the cache is process-global rather than per-thread.
69-
_transports: Dict[Optional[str], "ApiPyqwestTransport"] = {}
70+
_transports: Dict[Optional[ProxyConfig], "ApiPyqwestTransport"] = {}
7071

7172

7273
def get_transport(config: ConnectionConfig) -> "ApiPyqwestTransport":
7374
"""The shared pyqwest-backed httpx transport for REST API calls. For TLS
7475
connections ALPN negotiates the HTTP version (HTTP/2 against the E2B
75-
API), like the http2-enabled httpx transport this replaced."""
76-
proxy_url = proxy_to_url(config.proxy)
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."""
82+
proxy = proxy_to_config(config.proxy)
7783
with _transport_lock:
78-
transport = _transports.get(proxy_url)
84+
transport = _transports.get(proxy)
7985
if transport is None:
8086
transport = ApiPyqwestTransport(
8187
ConnectionRetryTransport(
8288
SyncHTTPTransport(
8389
tls_include_system_certs=True,
84-
proxy=proxy_url,
90+
proxy=proxy.to_pyqwest() if proxy is not None else None,
8591
pool_idle_timeout=pool_idle_timeout,
8692
pool_max_idle_per_host=pool_max_idle_per_host,
8793
),
8894
max_retries=connection_retries,
8995
)
9096
)
91-
_transports[proxy_url] = transport
97+
_transports[proxy] = transport
9298
return transport
9399

94100

packages/python-sdk/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ dependencies = [
1818
"dockerfile-parse>=2.0.1,<3",
1919
"rich>=14.0.0",
2020
"connectrpc>=0.11.1,<0.12",
21-
"pyqwest>=0.7.0,<0.8",
21+
"pyqwest>=0.8.0,<0.9",
2222
]
2323

2424
[project.urls]

0 commit comments

Comments
 (0)