From b0e4dc29cb69c720fcfe675fc86df7e961d72264 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:01:46 +0200 Subject: [PATCH 01/10] feat(python-sdk): move the REST API client onto pyqwest's httpx transport adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the httpx-native HTTPTransport/AsyncHTTPTransport under the generated REST API client for pyqwest (Rust reqwest/hyper) via pyqwest.httpx. The httpx client surface is unchanged; the transports are now thread-safe and loop-independent, so the pool is cached process-wide per proxy. Strip the Host header httpx adds — forwarding it on HTTP/2 makes the API edge reset the stream with PROTOCOL_ERROR. envd traffic and the volume content client stay on httpx (the streaming download path needs httpx's per-read idle timeout, which the adapter's whole-request deadline can't express). Co-Authored-By: Claude Fable 5 --- .changeset/python-pyqwest-rest-transport.md | 25 ++ packages/python-sdk/e2b/api/__init__.py | 28 ++ .../e2b/api/client_async/__init__.py | 109 +++++-- .../e2b/api/client_sync/__init__.py | 100 ++++-- .../tests/test_api_client_transport.py | 298 ++++++++++++------ 5 files changed, 413 insertions(+), 147 deletions(-) create mode 100644 .changeset/python-pyqwest-rest-transport.md diff --git a/.changeset/python-pyqwest-rest-transport.md b/.changeset/python-pyqwest-rest-transport.md new file mode 100644 index 0000000000..f16f2beb7a --- /dev/null +++ b/.changeset/python-pyqwest-rest-transport.md @@ -0,0 +1,25 @@ +--- +"@e2b/python-sdk": minor +--- + +Move the REST API client (sandbox lifecycle, listing, templates, volumes +control plane) onto [`pyqwest`](https://pypi.org/project/pyqwest/) (Rust +reqwest/hyper) via its httpx-compatible transport adapter, replacing the +httpx-native `HTTPTransport`/`AsyncHTTPTransport`. The generated httpx client +API is unchanged — only the transport underneath is swapped — so logging +event hooks, per-request timeouts, and headers behave as before. + +Because pyqwest transports are thread-safe and loop-independent (I/O runs on +a Rust runtime), the API connection pool is now shared process-wide per +proxy, instead of one pool per thread (sync) or per event loop (async). +Connection-establishment failures are retried with backoff +(`E2B_CONNECTION_RETRIES`, default 3), matching the connect-only retries of +the previous transports. + +`proxy` for API calls must now be a URL string (e.g. +`proxy="http://user:pass@localhost:8030"`, scheme http, https, socks5, or +socks5h); the richer `httpx.Proxy` objects are rejected with +`InvalidArgumentException` rather than partially honored. + +envd traffic (sandbox commands, filesystem, PTY, file transfers) is not +affected and stays on its existing stack. diff --git a/packages/python-sdk/e2b/api/__init__.py b/packages/python-sdk/e2b/api/__init__.py index 15ef836bfb..85a42ddb77 100644 --- a/packages/python-sdk/e2b/api/__init__.py +++ b/packages/python-sdk/e2b/api/__init__.py @@ -11,6 +11,7 @@ import httpx from httpx import AsyncBaseTransport, BaseTransport, Limits, Timeout +from httpx._types import ProxyTypes from e2b.api.client.client import AuthenticatedClient from e2b.api.client.types import Response @@ -18,6 +19,7 @@ from e2b.connection_config import ConnectionConfig from e2b.exceptions import ( AuthenticationException, + InvalidArgumentException, RateLimitException, SandboxException, ) @@ -70,6 +72,32 @@ async def on_response(response: Response) -> None: connection_retries = int(os.getenv("E2B_CONNECTION_RETRIES") or "3") +# Mirror the httpx pool tuning above with pyqwest's equivalents for the REST +# API transports. `pool_max_idle_per_host` is per host rather than httpx's +# global idle cap, but API traffic goes to a single host, so the values map +# directly; reqwest has no counterpart to `E2B_MAX_CONNECTIONS` (it does not +# cap concurrent connections). +pool_idle_timeout = float(os.getenv("E2B_KEEPALIVE_EXPIRY") or "300") +pool_max_idle_per_host = int(os.getenv("E2B_MAX_KEEPALIVE_CONNECTIONS") or "20") + + +def proxy_to_url(proxy: Optional[ProxyTypes]) -> Optional[str]: + """Narrow the ``proxy`` connection option to the proxy URL string pyqwest + transports take (scheme http, https, socks5, or socks5h, credentials in + the URL userinfo). The richer ``httpx.Proxy`` objects the httpx transports + accepted (per-proxy auth, headers, TLS context) are rejected rather than + partially honored.""" + if proxy is None: + return None + if isinstance(proxy, str): + return proxy + if isinstance(proxy, httpx.URL): + return str(proxy) + raise InvalidArgumentException( + "E2B API calls support only URL-string proxies, " + 'e.g. proxy="http://user:pass@localhost:8030"' + ) + @dataclass class SandboxCreateResponse: diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index dd001a3a41..37220132d0 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -1,12 +1,23 @@ import asyncio +import threading import weakref -from typing import Dict, Optional, Tuple +from typing import Dict, Optional, Tuple, Union import httpx from httpx._types import ProxyTypes - -from e2b.api import AsyncApiClient, connection_retries, limits +from pyqwest import HTTPTransport, Request, Response +from pyqwest.httpx import AsyncPyqwestTransport +from pyqwest.middleware.retry import RetryTransport + +from e2b.api import ( + AsyncApiClient, + connection_retries, + limits, + pool_idle_timeout, + pool_max_idle_per_host, + proxy_to_url, +) from e2b.connection_config import ConnectionConfig TransportKey = Tuple[bool, Optional[ProxyTypes]] @@ -20,13 +31,72 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient: ) -class AsyncTransportWithLogger(httpx.AsyncHTTPTransport): +class AsyncApiPyqwestTransport(AsyncPyqwestTransport): + """Strip the ``Host`` header httpx adds to every request: hyper derives + HTTP/1 ``Host`` and HTTP/2 ``:authority`` from the URL itself, and + forwarding an explicit ``host`` header on an HTTP/2 connection makes the + E2B API edge reset the stream with PROTOCOL_ERROR. (Custom ``Host`` + overrides are therefore not honored, matching hyper's URL-derived + behavior.)""" + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + if "host" in request.headers: + del request.headers["host"] + return await super().handle_async_request(request) + + +class ConnectionRetryTransport(RetryTransport): + """Retry only failures establishing the connection, matching the + connect-only ``retries`` of the httpx transport this replaced: pyqwest + raises the builtin ``ConnectionError`` only before the request was + written, so these retries can never replay a request the API may have + received. The retry middleware's default policy would otherwise also + retry I/O errors and 429/5xx responses for idempotent methods.""" + + def should_retry_response( + self, request: Request, response: Union[Response, Exception] + ) -> bool: + return isinstance(response, ConnectionError) + + +_transport_lock = threading.Lock() +# One transport (= one connection pool) per proxy; None is the direct pool. +# pyqwest's I/O runs on its own Rust runtime, so unlike the httpx envd +# transports below, the transport is not bound to an event loop and the +# cache is process-global rather than per-loop. +_transports: Dict[Optional[str], "AsyncApiPyqwestTransport"] = {} + + +def get_transport(config: ConnectionConfig) -> "AsyncApiPyqwestTransport": + """The shared pyqwest-backed httpx transport for REST API calls. For TLS + connections ALPN negotiates the HTTP version (HTTP/2 against the E2B + API), like the http2-enabled httpx transport this replaced.""" + proxy_url = proxy_to_url(config.proxy) + with _transport_lock: + transport = _transports.get(proxy_url) + if transport is None: + transport = AsyncApiPyqwestTransport( + ConnectionRetryTransport( + HTTPTransport( + tls_include_system_certs=True, + proxy=proxy_url, + pool_idle_timeout=pool_idle_timeout, + pool_max_idle_per_host=pool_max_idle_per_host, + ), + max_retries=connection_retries, + ) + ) + _transports[proxy_url] = transport + return transport + + +class AsyncEnvdTransportWithLogger(httpx.AsyncHTTPTransport): # Keyed weakly by the event loop object itself, not id(loop) — CPython # reuses object ids, so a new loop could otherwise inherit a transport # bound to a previous, closed loop. _instances: weakref.WeakKeyDictionary[ asyncio.AbstractEventLoop, - Dict[TransportKey, "AsyncTransportWithLogger"], + Dict[TransportKey, "AsyncEnvdTransportWithLogger"], ] = weakref.WeakKeyDictionary() @property @@ -34,17 +104,19 @@ def pool(self): return self._pool -def _get_cached_transport(cls, config: ConnectionConfig, http2: bool): +def get_envd_transport( + config: ConnectionConfig, http2: bool = True +) -> AsyncEnvdTransportWithLogger: loop = asyncio.get_running_loop() - loop_instances = cls._instances.get(loop) + loop_instances = AsyncEnvdTransportWithLogger._instances.get(loop) if loop_instances is None: loop_instances = {} - cls._instances[loop] = loop_instances + AsyncEnvdTransportWithLogger._instances[loop] = loop_instances key: TransportKey = (http2, config.proxy) transport = loop_instances.get(key) if transport is None: - transport = cls( + transport = AsyncEnvdTransportWithLogger( limits=limits, proxy=config.proxy, http2=http2, @@ -53,22 +125,3 @@ def _get_cached_transport(cls, config: ConnectionConfig, http2: bool): loop_instances[key] = transport return transport - - -def get_transport( - config: ConnectionConfig, http2: bool = True -) -> AsyncTransportWithLogger: - return _get_cached_transport(AsyncTransportWithLogger, config, http2) - - -class AsyncEnvdTransportWithLogger(AsyncTransportWithLogger): - _instances: weakref.WeakKeyDictionary[ - asyncio.AbstractEventLoop, - Dict[TransportKey, "AsyncEnvdTransportWithLogger"], - ] = weakref.WeakKeyDictionary() - - -def get_envd_transport( - config: ConnectionConfig, http2: bool = True -) -> AsyncEnvdTransportWithLogger: - return _get_cached_transport(AsyncEnvdTransportWithLogger, config, http2) diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index 41fb4f023d..7d1eb5332b 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -1,11 +1,21 @@ -from typing import Dict, Optional, Tuple +from typing import Dict, Optional, Tuple, Union import httpx import threading from httpx._types import ProxyTypes - -from e2b.api import ApiClient, connection_retries, limits +from pyqwest import SyncHTTPTransport, SyncRequest, SyncResponse +from pyqwest.httpx import PyqwestTransport +from pyqwest.middleware.retry import SyncRetryTransport + +from e2b.api import ( + ApiClient, + connection_retries, + limits, + pool_idle_timeout, + pool_max_idle_per_host, + proxy_to_url, +) from e2b.connection_config import ConnectionConfig TransportKey = Tuple[bool, Optional[ProxyTypes]] @@ -19,7 +29,65 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient: ) -class TransportWithLogger(httpx.HTTPTransport): +class ApiPyqwestTransport(PyqwestTransport): + """Strip the ``Host`` header httpx adds to every request: hyper derives + HTTP/1 ``Host`` and HTTP/2 ``:authority`` from the URL itself, and + forwarding an explicit ``host`` header on an HTTP/2 connection makes the + E2B API edge reset the stream with PROTOCOL_ERROR. (Custom ``Host`` + overrides are therefore not honored, matching hyper's URL-derived + behavior.)""" + + def handle_request(self, request: httpx.Request) -> httpx.Response: + if "host" in request.headers: + del request.headers["host"] + return super().handle_request(request) + + +class ConnectionRetryTransport(SyncRetryTransport): + """Retry only failures establishing the connection, matching the + connect-only ``retries`` of the httpx transport this replaced: pyqwest + raises the builtin ``ConnectionError`` only before the request was + written, so these retries can never replay a request the API may have + received. The retry middleware's default policy would otherwise also + retry I/O errors and 429/5xx responses for idempotent methods.""" + + def should_retry_response( + self, request: SyncRequest, response: Union[SyncResponse, Exception] + ) -> bool: + return isinstance(response, ConnectionError) + + +_transport_lock = threading.Lock() +# One transport (= one connection pool) per proxy; None is the direct pool. +# pyqwest transports are thread-safe, so unlike the httpx envd transports +# below, the cache is process-global rather than per-thread. +_transports: Dict[Optional[str], "ApiPyqwestTransport"] = {} + + +def get_transport(config: ConnectionConfig) -> "ApiPyqwestTransport": + """The shared pyqwest-backed httpx transport for REST API calls. For TLS + connections ALPN negotiates the HTTP version (HTTP/2 against the E2B + API), like the http2-enabled httpx transport this replaced.""" + proxy_url = proxy_to_url(config.proxy) + with _transport_lock: + transport = _transports.get(proxy_url) + if transport is None: + transport = ApiPyqwestTransport( + ConnectionRetryTransport( + SyncHTTPTransport( + tls_include_system_certs=True, + proxy=proxy_url, + pool_idle_timeout=pool_idle_timeout, + pool_max_idle_per_host=pool_max_idle_per_host, + ), + max_retries=connection_retries, + ) + ) + _transports[proxy_url] = transport + return transport + + +class EnvdTransportWithLogger(httpx.HTTPTransport): _thread_local = threading.local() @property @@ -27,30 +95,6 @@ def pool(self): return self._pool -def get_transport(config: ConnectionConfig, http2: bool = True) -> TransportWithLogger: - instances: Dict[TransportKey, TransportWithLogger] = getattr( - TransportWithLogger._thread_local, "instances", {} - ) - key: TransportKey = (http2, config.proxy) - cached = instances.get(key) - if cached is not None: - return cached - - transport = TransportWithLogger( - limits=limits, - proxy=config.proxy, - http2=http2, - retries=connection_retries, - ) - instances[key] = transport - TransportWithLogger._thread_local.instances = instances - return transport - - -class EnvdTransportWithLogger(TransportWithLogger): - _thread_local = threading.local() - - def get_envd_transport( config: ConnectionConfig, http2: bool = True ) -> EnvdTransportWithLogger: diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index e54848e98a..04128c6edd 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -1,23 +1,45 @@ import asyncio import gc +import json +import threading from concurrent.futures import ThreadPoolExecutor +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import cast import httpx import pytest - -from e2b.api.client_async import AsyncEnvdTransportWithLogger, AsyncTransportWithLogger +from pyqwest import ( + Headers, + Request, + Response, + SyncRequest, + SyncResponse, + SyncTransport, + Transport, +) +from pyqwest.httpx import AsyncPyqwestTransport, PyqwestTransport + +import e2b.api.client_async as client_async +import e2b.api.client_sync as client_sync +from e2b.api import proxy_to_url +from e2b.api.client_async import AsyncEnvdTransportWithLogger from e2b.api.client_async import get_api_client as get_async_api_client from e2b.api.client_async import get_envd_transport as get_async_envd_transport from e2b.api.client_async import get_transport as get_async_transport -from e2b.api.client_sync import EnvdTransportWithLogger, TransportWithLogger +from e2b.api.client_sync import EnvdTransportWithLogger from e2b.api.client_sync import get_api_client as get_sync_api_client from e2b.api.client_sync import get_envd_transport as get_sync_envd_transport from e2b.api.client_sync import get_transport as get_sync_transport from e2b.connection_config import ConnectionConfig +from e2b.exceptions import InvalidArgumentException def reset_sync_api_transports(): - TransportWithLogger._thread_local.instances = {} + client_sync._transports.clear() + + +def reset_async_api_transports(): + client_async._transports.clear() def reset_sync_envd_transports(): @@ -29,6 +51,72 @@ def run_in_worker_thread(fn): return executor.submit(fn).result() +def test_proxy_to_url_narrows_to_url_strings(): + assert proxy_to_url(None) is None + assert proxy_to_url("http://127.0.0.1:9999") == "http://127.0.0.1:9999" + assert proxy_to_url(httpx.URL("http://127.0.0.1:9999")) == "http://127.0.0.1:9999" + with pytest.raises(InvalidArgumentException): + proxy_to_url(httpx.Proxy("http://127.0.0.1:9999")) + + +def test_connection_retry_policy_retries_only_connection_errors(): + # The inner transport is never invoked by should_retry_response. + sync_policy = client_sync.ConnectionRetryTransport(cast(SyncTransport, object())) + async_policy = client_async.ConnectionRetryTransport(cast(Transport, object())) + sync_request = SyncRequest("GET", "https://example.com") + async_request = Request("GET", "https://example.com") + + assert sync_policy.should_retry_response(sync_request, ConnectionError("refused")) + # TimeoutError is an OSError but not a ConnectionError: the request + # may have been written, so it must not be replayed. + assert not sync_policy.should_retry_response(sync_request, TimeoutError()) + assert not sync_policy.should_retry_response(sync_request, RuntimeError()) + + assert async_policy.should_retry_response(async_request, ConnectionError("refused")) + assert not async_policy.should_retry_response(async_request, TimeoutError()) + assert not async_policy.should_retry_response(async_request, RuntimeError()) + + +def test_sync_api_transport_strips_host_header(): + # Forwarding httpx's auto-added Host header on an HTTP/2 connection makes + # the E2B API edge reset the stream with PROTOCOL_ERROR; hyper derives + # Host/:authority from the URL instead. + captured = {} + + class FakeInner: + def execute_sync(self, request): + captured["headers"] = dict(request.headers.items()) + return SyncResponse(status=200, headers=Headers(()), content=b"") + + transport = client_sync.ApiPyqwestTransport(FakeInner()) + request = httpx.Request("GET", "https://api.example.com/health") + assert "host" in request.headers + + response = transport.handle_request(request) + + assert response.status_code == 200 + assert "host" not in captured["headers"] + + +@pytest.mark.asyncio +async def test_async_api_transport_strips_host_header(): + captured = {} + + class FakeInner: + async def execute(self, request): + captured["headers"] = dict(request.headers.items()) + return Response(status=200, headers=Headers(()), content=b"") + + transport = client_async.AsyncApiPyqwestTransport(FakeInner()) + request = httpx.Request("GET", "https://api.example.com/health") + assert "host" in request.headers + + response = await transport.handle_async_request(request) + + assert response.status_code == 200 + assert "host" not in captured["headers"] + + def test_sync_api_client_proxy_uses_explicit_transport(test_api_key): reset_sync_api_transports() config = ConnectionConfig( @@ -42,31 +130,13 @@ def test_sync_api_client_proxy_uses_explicit_transport(test_api_key): try: assert "proxy" not in api_client._httpx_args assert httpx_client._transport is get_sync_transport(config) + assert isinstance(httpx_client._transport, PyqwestTransport) assert httpx_client._mounts == {} finally: httpx_client.close() reset_sync_api_transports() -def test_sync_get_transport_http2_opt_out_returns_distinct_instance(test_api_key): - reset_sync_api_transports() - config = ConnectionConfig(api_key=test_api_key) - - try: - http2_transport = get_sync_transport(config) - http1_transport = get_sync_transport(config, http2=False) - - assert http2_transport is not http1_transport - assert http2_transport._pool._http2 is True - assert http1_transport._pool._http2 is False - # Subsequent calls with the same http2 flag return the cached - # instance. - assert get_sync_transport(config) is http2_transport - assert get_sync_transport(config, http2=False) is http1_transport - finally: - reset_sync_api_transports() - - def test_sync_get_transport_keyed_by_proxy(test_api_key): reset_sync_api_transports() proxied_config = ConnectionConfig( @@ -122,7 +192,9 @@ def test_sync_api_client_request_timeout_zero_disables_timeout(test_api_key): reset_sync_api_transports() -def test_sync_envd_transport_uses_separate_cache(test_api_key): +def test_sync_envd_transport_uses_separate_stack(test_api_key): + # envd file-transfer traffic stays on the httpx-native transport (its RPC + # migration is a separate change); only REST API calls go through pyqwest. reset_sync_api_transports() reset_sync_envd_transports() config = ConnectionConfig(api_key=test_api_key) @@ -131,18 +203,20 @@ def test_sync_envd_transport_uses_separate_cache(test_api_key): api_transport = get_sync_transport(config) envd_transport = get_sync_envd_transport(config) - assert api_transport is not envd_transport + assert isinstance(api_transport, PyqwestTransport) + assert isinstance(envd_transport, httpx.HTTPTransport) assert get_sync_transport(config) is api_transport assert get_sync_envd_transport(config) is envd_transport - assert envd_transport._pool._http2 is True + envd_pool = envd_transport._pool + assert envd_pool._http2 is True # ty: ignore[possibly-missing-attribute] finally: reset_sync_api_transports() reset_sync_envd_transports() -def test_sync_api_transport_cache_reuses_within_thread_and_isolates_across_threads( - test_api_key, -): +def test_sync_api_transport_cache_is_shared_across_threads(test_api_key): + # pyqwest transports are thread-safe, so all threads share one connection + # pool per proxy (the httpx transports this replaced were per-thread). reset_sync_api_transports() config = ConnectionConfig(api_key=test_api_key) @@ -154,12 +228,12 @@ def test_sync_api_transport_cache_reuses_within_thread_and_isolates_across_threa ) assert same_thread_transport is main_transport - assert worker_thread_transport is not main_transport + assert worker_thread_transport is main_transport finally: reset_sync_api_transports() -def test_sync_api_client_cache_reuses_within_thread_and_isolates_across_threads( +def test_sync_api_client_reused_within_thread_and_shares_transport_across_threads( test_api_key, ): reset_sync_api_transports() @@ -173,7 +247,6 @@ def get_worker_client_ids(): id(httpx_client), id(api_client.get_httpx_client()), id(httpx_client._transport), - id(get_sync_transport(config)), ) finally: httpx_client.close() @@ -184,14 +257,14 @@ def get_worker_client_ids(): worker_client_id, worker_cached_client_id, worker_transport_id, - worker_cached_transport_id, ) = run_in_worker_thread(get_worker_client_ids) assert api_client.get_httpx_client() is main_client + # The httpx client wrapper stays per-thread, but every thread's + # client delegates to the same shared pyqwest transport. assert worker_client_id == worker_cached_client_id - assert worker_transport_id == worker_cached_transport_id assert worker_client_id != id(main_client) - assert worker_transport_id != id(main_client._transport) + assert worker_transport_id == id(main_client._transport) finally: main_client.close() reset_sync_api_transports() @@ -207,7 +280,8 @@ def test_sync_envd_transport_cache_is_thread_local(test_api_key): assert main_transport is get_sync_envd_transport(config) assert thread_transport is not main_transport - assert main_transport._pool._http2 is True + main_pool = main_transport._pool + assert main_pool._http2 is True # ty: ignore[possibly-missing-attribute] assert thread_transport._pool._http2 is True finally: reset_sync_envd_transports() @@ -215,7 +289,7 @@ def test_sync_envd_transport_cache_is_thread_local(test_api_key): @pytest.mark.asyncio async def test_async_api_client_proxy_uses_explicit_transport(test_api_key): - AsyncTransportWithLogger._instances.clear() + reset_async_api_transports() config = ConnectionConfig( api_key=test_api_key, proxy="http://127.0.0.1:9999", @@ -223,44 +297,20 @@ async def test_async_api_client_proxy_uses_explicit_transport(test_api_key): api_client = get_async_api_client(config) httpx_client = api_client.get_async_httpx_client() - transport = AsyncTransportWithLogger._instances[asyncio.get_running_loop()][ - (True, "http://127.0.0.1:9999") - ] try: assert "proxy" not in api_client._httpx_args - assert httpx_client._transport is transport + assert httpx_client._transport is get_async_transport(config) + assert isinstance(httpx_client._transport, AsyncPyqwestTransport) assert httpx_client._mounts == {} finally: await httpx_client.aclose() - AsyncTransportWithLogger._instances.clear() - - -@pytest.mark.asyncio -async def test_async_get_transport_http2_opt_out_returns_distinct_instance( - test_api_key, -): - AsyncTransportWithLogger._instances.clear() - config = ConnectionConfig(api_key=test_api_key) - - try: - http2_transport = get_async_transport(config) - http1_transport = get_async_transport(config, http2=False) - - assert http2_transport is not http1_transport - assert http2_transport._pool._http2 is True - assert http1_transport._pool._http2 is False - # Subsequent calls with the same http2 flag return the cached - # instance. - assert get_async_transport(config) is http2_transport - assert get_async_transport(config, http2=False) is http1_transport - finally: - AsyncTransportWithLogger._instances.clear() + reset_async_api_transports() @pytest.mark.asyncio async def test_async_get_transport_keyed_by_proxy(test_api_key): - AsyncTransportWithLogger._instances.clear() + reset_async_api_transports() proxied_config = ConnectionConfig( api_key=test_api_key, proxy="http://127.0.0.1:9999", @@ -276,12 +326,12 @@ async def test_async_get_transport_keyed_by_proxy(test_api_key): assert get_async_transport(proxied_config) is proxied_transport assert get_async_transport(direct_config) is direct_transport finally: - AsyncTransportWithLogger._instances.clear() + reset_async_api_transports() @pytest.mark.asyncio async def test_async_api_client_applies_request_timeout(test_api_key): - AsyncTransportWithLogger._instances.clear() + reset_async_api_transports() config = ConnectionConfig(api_key=test_api_key, request_timeout=1.5) api_client = get_async_api_client(config) @@ -291,14 +341,14 @@ async def test_async_api_client_applies_request_timeout(test_api_key): assert httpx_client.timeout == httpx.Timeout(1.5) finally: await httpx_client.aclose() - AsyncTransportWithLogger._instances.clear() + reset_async_api_transports() @pytest.mark.asyncio -async def test_async_api_client_cache_reuses_within_loop_and_isolates_across_loops( +async def test_async_api_client_cache_reuses_within_loop_and_shares_transport( test_api_key, ): - AsyncTransportWithLogger._instances.clear() + reset_async_api_transports() config = ConnectionConfig(api_key=test_api_key) api_client = get_async_api_client(config) @@ -309,7 +359,6 @@ async def get_client_ids(): id(httpx_client), id(api_client.get_async_httpx_client()), id(httpx_client._transport), - id(get_async_transport(config)), ) finally: await httpx_client.aclose() @@ -320,24 +369,25 @@ async def get_client_ids(): worker_client_id, worker_cached_client_id, worker_transport_id, - worker_cached_transport_id, ) = await asyncio.get_running_loop().run_in_executor( None, lambda: asyncio.run(get_client_ids()), ) assert api_client.get_async_httpx_client() is main_client + # The httpx client wrapper stays per-loop, but pyqwest's I/O runs on + # its own Rust runtime, so every loop's client delegates to the same + # shared transport. assert worker_client_id == worker_cached_client_id - assert worker_transport_id == worker_cached_transport_id assert worker_client_id != id(main_client) - assert worker_transport_id != id(main_client._transport) + assert worker_transport_id == id(main_client._transport) finally: await main_client.aclose() - AsyncTransportWithLogger._instances.clear() + reset_async_api_transports() -def test_async_transport_not_reused_across_sequential_loops(test_api_key): - AsyncTransportWithLogger._instances.clear() +def test_async_transport_reused_across_sequential_loops(test_api_key): + reset_async_api_transports() config = ConnectionConfig(api_key=test_api_key) async def get_transport(): @@ -352,24 +402,21 @@ async def get_transport(): del loop_a gc.collect() - # The cache entry dies with the loop, so a later loop can never - # inherit a transport bound to a closed loop, even when CPython - # reuses the dead loop's object id. - assert len(AsyncTransportWithLogger._instances) == 0 - + # The transport is not bound to an event loop, so it survives the + # loop it was first created on. loop_b = asyncio.new_event_loop() try: transport_b = loop_b.run_until_complete(get_transport()) finally: loop_b.close() - assert transport_b is not transport_a + assert transport_b is transport_a finally: - AsyncTransportWithLogger._instances.clear() + reset_async_api_transports() def test_async_api_client_not_reused_across_sequential_loops(test_api_key): - AsyncTransportWithLogger._instances.clear() + reset_async_api_transports() config = ConnectionConfig(api_key=test_api_key) api_client = get_async_api_client(config) @@ -399,12 +446,12 @@ async def get_client(): assert client_b is not client_a finally: - AsyncTransportWithLogger._instances.clear() + reset_async_api_transports() @pytest.mark.asyncio -async def test_async_envd_transport_uses_separate_cache(test_api_key): - AsyncTransportWithLogger._instances.clear() +async def test_async_envd_transport_uses_separate_stack(test_api_key): + reset_async_api_transports() AsyncEnvdTransportWithLogger._instances.clear() config = ConnectionConfig(api_key=test_api_key) @@ -412,10 +459,79 @@ async def test_async_envd_transport_uses_separate_cache(test_api_key): api_transport = get_async_transport(config) envd_transport = get_async_envd_transport(config) - assert api_transport is not envd_transport + assert isinstance(api_transport, AsyncPyqwestTransport) + assert isinstance(envd_transport, httpx.AsyncHTTPTransport) assert get_async_transport(config) is api_transport assert get_async_envd_transport(config) is envd_transport - assert envd_transport._pool._http2 is True + envd_pool = envd_transport._pool + assert envd_pool._http2 is True # ty: ignore[possibly-missing-attribute] finally: - AsyncTransportWithLogger._instances.clear() + reset_async_api_transports() AsyncEnvdTransportWithLogger._instances.clear() + + +class _EchoHandler(BaseHTTPRequestHandler): + """Answers every GET with a JSON echo of the request headers.""" + + def do_GET(self): + headers = {k.lower(): v for k, v in self.headers.items()} + body = json.dumps({"path": self.path, "headers": headers}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + +@pytest.fixture +def echo_server(): + server = ThreadingHTTPServer(("127.0.0.1", 0), _EchoHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_address[1]}" + finally: + server.shutdown() + thread.join() + + +def test_sync_api_client_round_trips_through_pyqwest(test_api_key, echo_server): + reset_sync_api_transports() + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) + api_client = get_sync_api_client(config) + httpx_client = api_client.get_httpx_client() + + try: + assert isinstance(httpx_client._transport, PyqwestTransport) + response = httpx_client.request("GET", "/sandboxes") + assert response.status_code == 200 + echoed = response.json() + assert echoed["path"] == "/sandboxes" + assert echoed["headers"]["x-api-key"] == test_api_key + assert echoed["headers"]["package_version"] + finally: + httpx_client.close() + reset_sync_api_transports() + + +@pytest.mark.asyncio +async def test_async_api_client_round_trips_through_pyqwest(test_api_key, echo_server): + reset_async_api_transports() + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) + api_client = get_async_api_client(config) + httpx_client = api_client.get_async_httpx_client() + + try: + assert isinstance(httpx_client._transport, AsyncPyqwestTransport) + response = await httpx_client.request("GET", "/sandboxes") + assert response.status_code == 200 + echoed = response.json() + assert echoed["path"] == "/sandboxes" + assert echoed["headers"]["x-api-key"] == test_api_key + assert echoed["headers"]["package_version"] + finally: + await httpx_client.aclose() + reset_async_api_transports() From 7355db6f34f4ecae22e1c33280ffc2993fef3789 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:15:20 +0200 Subject: [PATCH 02/10] refactor(python-sdk): drop per-thread/per-loop client caching from ApiClient With the pyqwest transports thread-safe and loop-independent, the transport_factory/async_transport_factory plumbing, the thread-local httpx.Client cache, and the per-loop WeakKeyDictionary of AsyncClients are dead weight: a single lazily-created httpx client (the generated base behavior, same shape the volume client already uses) serves all threads and event loops over the shared per-proxy pool. Co-Authored-By: Claude Fable 5 --- .changeset/python-pyqwest-rest-transport.md | 4 +- packages/python-sdk/e2b/api/__init__.py | 81 ++---------- .../e2b/api/client_async/__init__.py | 6 +- .../e2b/api/client_sync/__init__.py | 6 +- .../tests/test_api_client_transport.py | 116 +++++++++--------- 5 files changed, 75 insertions(+), 138 deletions(-) diff --git a/.changeset/python-pyqwest-rest-transport.md b/.changeset/python-pyqwest-rest-transport.md index f16f2beb7a..140c117905 100644 --- a/.changeset/python-pyqwest-rest-transport.md +++ b/.changeset/python-pyqwest-rest-transport.md @@ -11,7 +11,9 @@ event hooks, per-request timeouts, and headers behave as before. Because pyqwest transports are thread-safe and loop-independent (I/O runs on a Rust runtime), the API connection pool is now shared process-wide per -proxy, instead of one pool per thread (sync) or per event loop (async). +proxy, instead of one pool per thread (sync) or per event loop (async), and +`ApiClient` no longer maintains per-thread/per-loop httpx client caches — a +single httpx client serves all threads and event loops. Connection-establishment failures are retried with backoff (`E2B_CONNECTION_RETRIES`, default 3), matching the connect-only retries of the previous transports. diff --git a/packages/python-sdk/e2b/api/__init__.py b/packages/python-sdk/e2b/api/__init__.py index 85a42ddb77..6859ef177b 100644 --- a/packages/python-sdk/e2b/api/__init__.py +++ b/packages/python-sdk/e2b/api/__init__.py @@ -1,13 +1,10 @@ -import asyncio import json import logging import os import re -import threading -import weakref from dataclasses import dataclass from types import TracebackType -from typing import Callable, Optional, Protocol, Union +from typing import Optional, Protocol, Union import httpx from httpx import AsyncBaseTransport, BaseTransport, Limits, Timeout @@ -182,31 +179,20 @@ def validate_api_key(api_key: str) -> None: class ApiClient(AuthenticatedClient): """ The client for interacting with the E2B API. + + A single lazily-created httpx client (see the generated + ``AuthenticatedClient``) serves all threads and event loops: the pyqwest + transports it delegates to are thread-safe and loop-independent, and + ``httpx.Client`` is documented thread-safe. """ def __init__( self, config: ConnectionConfig, transport: Optional[Union[BaseTransport, AsyncBaseTransport]] = None, - transport_factory: Optional[Callable[[], BaseTransport]] = None, - async_transport_factory: Optional[Callable[[], AsyncBaseTransport]] = None, *args, **kwargs, ): - if transport is not None and ( - transport_factory is not None or async_transport_factory is not None - ): - raise ValueError("Use either transport or transport_factory, not both") - - self._transport_factory = transport_factory - self._async_transport_factory = async_transport_factory - self._thread_local = threading.local() - # Keyed weakly by the event loop object itself, not id(loop) — - # CPython reuses object ids, so a new loop could otherwise inherit - # a client bound to a previous, closed loop. - self._async_clients: weakref.WeakKeyDictionary[ - asyncio.AbstractEventLoop, httpx.AsyncClient - ] = weakref.WeakKeyDictionary() self._proxy = config.proxy if config.api_key is None: @@ -251,12 +237,10 @@ def __init__( "event_hooks": self._logging_event_hooks(), } if transport is not None: + # The proxy lives in the transport; passing `proxy` here too + # would mount a fresh, never-closed proxy transport per client. httpx_args["transport"] = transport - if ( - transport is None - and transport_factory is None - and async_transport_factory is None - ): + else: httpx_args["proxy"] = config.proxy # config.request_timeout is None when the timeout is explicitly @@ -277,53 +261,6 @@ def __init__( def _logging_event_hooks(self) -> dict: return make_logging_event_hooks(self._logger) - def _headers_with_auth(self) -> dict: - return { - **self._headers, - self.auth_header_name: ( - f"{self.prefix} {self.token}" if self.prefix else self.token - ), - } - - def get_httpx_client(self) -> httpx.Client: - if self._client is not None or self._transport_factory is None: - return super().get_httpx_client() - - client = getattr(self._thread_local, "client", None) - if client is None: - client = httpx.Client( - base_url=self._base_url, - cookies=self._cookies, - headers=self._headers_with_auth(), - timeout=self._timeout, - verify=self._verify_ssl, - follow_redirects=self._follow_redirects, - event_hooks=self._httpx_args.get("event_hooks"), - transport=self._transport_factory(), - ) - self._thread_local.client = client - return client - - def get_async_httpx_client(self) -> httpx.AsyncClient: - if self._async_client is not None or self._async_transport_factory is None: - return super().get_async_httpx_client() - - loop = asyncio.get_running_loop() - client = self._async_clients.get(loop) - if client is None: - client = httpx.AsyncClient( - base_url=self._base_url, - cookies=self._cookies, - headers=self._headers_with_auth(), - timeout=self._timeout, - verify=self._verify_ssl, - follow_redirects=self._follow_redirects, - event_hooks=self._httpx_args.get("event_hooks"), - transport=self._async_transport_factory(), - ) - self._async_clients[loop] = client - return client - # We need to override the logging hooks for the async usage class AsyncApiClient(ApiClient): diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index 37220132d0..674742283e 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -24,11 +24,7 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient: - return AsyncApiClient( - config, - async_transport_factory=lambda: get_transport(config), - **kwargs, - ) + return AsyncApiClient(config, transport=get_transport(config), **kwargs) class AsyncApiPyqwestTransport(AsyncPyqwestTransport): diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index 7d1eb5332b..7c68a7a3b7 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -22,11 +22,7 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient: - return ApiClient( - config, - transport_factory=lambda: get_transport(config), - **kwargs, - ) + return ApiClient(config, transport=get_transport(config), **kwargs) class ApiPyqwestTransport(PyqwestTransport): diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index 04128c6edd..817ddfc5b6 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -233,38 +233,20 @@ def test_sync_api_transport_cache_is_shared_across_threads(test_api_key): reset_sync_api_transports() -def test_sync_api_client_reused_within_thread_and_shares_transport_across_threads( - test_api_key, -): +def test_sync_api_client_is_shared_across_threads(test_api_key): + # httpx.Client is thread-safe and the pyqwest transport underneath is + # too, so a single client (and its pool) serves all threads — the + # per-thread client caching this replaced is gone. reset_sync_api_transports() config = ConnectionConfig(api_key=test_api_key) api_client = get_sync_api_client(config) - def get_worker_client_ids(): - httpx_client = api_client.get_httpx_client() - try: - return ( - id(httpx_client), - id(api_client.get_httpx_client()), - id(httpx_client._transport), - ) - finally: - httpx_client.close() - try: main_client = api_client.get_httpx_client() - ( - worker_client_id, - worker_cached_client_id, - worker_transport_id, - ) = run_in_worker_thread(get_worker_client_ids) + worker_client = run_in_worker_thread(api_client.get_httpx_client) assert api_client.get_httpx_client() is main_client - # The httpx client wrapper stays per-thread, but every thread's - # client delegates to the same shared pyqwest transport. - assert worker_client_id == worker_cached_client_id - assert worker_client_id != id(main_client) - assert worker_transport_id == id(main_client._transport) + assert worker_client is main_client finally: main_client.close() reset_sync_api_transports() @@ -345,42 +327,27 @@ async def test_async_api_client_applies_request_timeout(test_api_key): @pytest.mark.asyncio -async def test_async_api_client_cache_reuses_within_loop_and_shares_transport( - test_api_key, -): +async def test_async_api_client_is_shared_across_loops(test_api_key): + # pyqwest's I/O runs on its own Rust runtime, so neither the transport + # nor the httpx client wrapper is bound to an event loop — a single + # client serves all loops (the per-loop client caching this replaced is + # gone). reset_async_api_transports() config = ConnectionConfig(api_key=test_api_key) api_client = get_async_api_client(config) - async def get_client_ids(): - httpx_client = api_client.get_async_httpx_client() - try: - return ( - id(httpx_client), - id(api_client.get_async_httpx_client()), - id(httpx_client._transport), - ) - finally: - await httpx_client.aclose() + async def get_client(): + return api_client.get_async_httpx_client() try: main_client = api_client.get_async_httpx_client() - ( - worker_client_id, - worker_cached_client_id, - worker_transport_id, - ) = await asyncio.get_running_loop().run_in_executor( + other_loop_client = await asyncio.get_running_loop().run_in_executor( None, - lambda: asyncio.run(get_client_ids()), + lambda: asyncio.run(get_client()), ) assert api_client.get_async_httpx_client() is main_client - # The httpx client wrapper stays per-loop, but pyqwest's I/O runs on - # its own Rust runtime, so every loop's client delegates to the same - # shared transport. - assert worker_client_id == worker_cached_client_id - assert worker_client_id != id(main_client) - assert worker_transport_id == id(main_client._transport) + assert other_loop_client is main_client finally: await main_client.aclose() reset_async_api_transports() @@ -415,7 +382,9 @@ async def get_transport(): reset_async_api_transports() -def test_async_api_client_not_reused_across_sequential_loops(test_api_key): +def test_async_api_client_reused_across_sequential_loops(test_api_key): + # A closed loop doesn't strand the client: nothing in it is loop-bound, + # so a later loop reuses the same client and shared transport. reset_async_api_transports() config = ConnectionConfig(api_key=test_api_key) api_client = get_async_api_client(config) @@ -429,22 +398,18 @@ async def get_client(): loop_a = asyncio.new_event_loop() try: client_a = loop_a.run_until_complete(get_client()) - loop_a.run_until_complete(client_a.aclose()) finally: loop_a.close() del loop_a gc.collect() - assert len(api_client._async_clients) == 0 - loop_b = asyncio.new_event_loop() try: client_b = loop_b.run_until_complete(get_client()) - loop_b.run_until_complete(client_b.aclose()) finally: loop_b.close() - assert client_b is not client_a + assert client_b is client_a finally: reset_async_api_transports() @@ -517,6 +482,47 @@ def test_sync_api_client_round_trips_through_pyqwest(test_api_key, echo_server): reset_sync_api_transports() +def test_sync_api_client_serves_concurrent_threads(test_api_key, echo_server): + # The scenario the removed per-thread client caching used to guard: one + # client, one shared pyqwest pool, many threads at once. + reset_sync_api_transports() + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) + api_client = get_sync_api_client(config) + httpx_client = api_client.get_httpx_client() + + def request(i: int) -> tuple[int, str]: + response = httpx_client.request("GET", f"/sandboxes/{i}") + return response.status_code, response.json()["path"] + + try: + with ThreadPoolExecutor(max_workers=16) as executor: + results = list(executor.map(request, range(32))) + + assert results == [(200, f"/sandboxes/{i}") for i in range(32)] + finally: + httpx_client.close() + reset_sync_api_transports() + + +@pytest.mark.asyncio +async def test_async_api_client_serves_concurrent_requests(test_api_key, echo_server): + reset_async_api_transports() + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) + api_client = get_async_api_client(config) + httpx_client = api_client.get_async_httpx_client() + + async def request(i: int) -> tuple[int, str]: + response = await httpx_client.request("GET", f"/sandboxes/{i}") + return response.status_code, response.json()["path"] + + try: + results = await asyncio.gather(*(request(i) for i in range(32))) + assert list(results) == [(200, f"/sandboxes/{i}") for i in range(32)] + finally: + await httpx_client.aclose() + reset_async_api_transports() + + @pytest.mark.asyncio async def test_async_api_client_round_trips_through_pyqwest(test_api_key, echo_server): reset_async_api_transports() From bc359da14133399699d19d5d8f9fcbb84aff1996 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:18:52 +0200 Subject: [PATCH 03/10] test(python-sdk): drop transport tests subsumed by client-level ones Transport identity across threads/loops is implied by the shared-client tests, sequential-loop reuse by concurrent-loop identity, and the async request-timeout wiring by the sync test through the now-shared __init__. Co-Authored-By: Claude Fable 5 --- .../tests/test_api_client_transport.py | 96 ------------------- 1 file changed, 96 deletions(-) diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index 817ddfc5b6..4bc4825678 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -1,5 +1,4 @@ import asyncio -import gc import json import threading from concurrent.futures import ThreadPoolExecutor @@ -214,25 +213,6 @@ def test_sync_envd_transport_uses_separate_stack(test_api_key): reset_sync_envd_transports() -def test_sync_api_transport_cache_is_shared_across_threads(test_api_key): - # pyqwest transports are thread-safe, so all threads share one connection - # pool per proxy (the httpx transports this replaced were per-thread). - reset_sync_api_transports() - config = ConnectionConfig(api_key=test_api_key) - - try: - main_transport = get_sync_transport(config) - same_thread_transport = get_sync_transport(config) - worker_thread_transport = run_in_worker_thread( - lambda: get_sync_transport(config) - ) - - assert same_thread_transport is main_transport - assert worker_thread_transport is main_transport - finally: - reset_sync_api_transports() - - def test_sync_api_client_is_shared_across_threads(test_api_key): # httpx.Client is thread-safe and the pyqwest transport underneath is # too, so a single client (and its pool) serves all threads — the @@ -311,21 +291,6 @@ async def test_async_get_transport_keyed_by_proxy(test_api_key): reset_async_api_transports() -@pytest.mark.asyncio -async def test_async_api_client_applies_request_timeout(test_api_key): - reset_async_api_transports() - config = ConnectionConfig(api_key=test_api_key, request_timeout=1.5) - - api_client = get_async_api_client(config) - httpx_client = api_client.get_async_httpx_client() - - try: - assert httpx_client.timeout == httpx.Timeout(1.5) - finally: - await httpx_client.aclose() - reset_async_api_transports() - - @pytest.mark.asyncio async def test_async_api_client_is_shared_across_loops(test_api_key): # pyqwest's I/O runs on its own Rust runtime, so neither the transport @@ -353,67 +318,6 @@ async def get_client(): reset_async_api_transports() -def test_async_transport_reused_across_sequential_loops(test_api_key): - reset_async_api_transports() - config = ConnectionConfig(api_key=test_api_key) - - async def get_transport(): - return get_async_transport(config) - - try: - loop_a = asyncio.new_event_loop() - try: - transport_a = loop_a.run_until_complete(get_transport()) - finally: - loop_a.close() - del loop_a - gc.collect() - - # The transport is not bound to an event loop, so it survives the - # loop it was first created on. - loop_b = asyncio.new_event_loop() - try: - transport_b = loop_b.run_until_complete(get_transport()) - finally: - loop_b.close() - - assert transport_b is transport_a - finally: - reset_async_api_transports() - - -def test_async_api_client_reused_across_sequential_loops(test_api_key): - # A closed loop doesn't strand the client: nothing in it is loop-bound, - # so a later loop reuses the same client and shared transport. - reset_async_api_transports() - config = ConnectionConfig(api_key=test_api_key) - api_client = get_async_api_client(config) - - async def get_client(): - client = api_client.get_async_httpx_client() - assert api_client.get_async_httpx_client() is client - return client - - try: - loop_a = asyncio.new_event_loop() - try: - client_a = loop_a.run_until_complete(get_client()) - finally: - loop_a.close() - del loop_a - gc.collect() - - loop_b = asyncio.new_event_loop() - try: - client_b = loop_b.run_until_complete(get_client()) - finally: - loop_b.close() - - assert client_b is client_a - finally: - reset_async_api_transports() - - @pytest.mark.asyncio async def test_async_envd_transport_uses_separate_stack(test_api_key): reset_async_api_transports() From 417d31385fb1cc783aef5f00f384879d8497af48 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:19:02 +0200 Subject: [PATCH 04/10] feat(python-sdk): reduce httpx.Proxy proxies to plain proxy URLs The API-client half of the former combined commit: proxy= accepts str, httpx.URL, and httpx.Proxy when it reduces to a plain proxy URL (auth folded into the userinfo); inexpressible extras (custom headers, ssl_context) raise InvalidArgumentException. Co-Authored-By: Claude Fable 5 --- .changeset/python-pyqwest-rest-transport.md | 9 ++++--- packages/python-sdk/e2b/api/__init__.py | 22 +++++++++++++--- .../tests/test_api_client_transport.py | 26 ++++++++++++++++++- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/.changeset/python-pyqwest-rest-transport.md b/.changeset/python-pyqwest-rest-transport.md index 140c117905..6117259300 100644 --- a/.changeset/python-pyqwest-rest-transport.md +++ b/.changeset/python-pyqwest-rest-transport.md @@ -18,10 +18,13 @@ Connection-establishment failures are retried with backoff (`E2B_CONNECTION_RETRIES`, default 3), matching the connect-only retries of the previous transports. -`proxy` for API calls must now be a URL string (e.g. +`proxy` for API calls now takes a URL string (e.g. `proxy="http://user:pass@localhost:8030"`, scheme http, https, socks5, or -socks5h); the richer `httpx.Proxy` objects are rejected with -`InvalidArgumentException` rather than partially honored. +socks5h). `httpx.URL` and `httpx.Proxy` keep working when they reduce to +such a URL (`httpx.Proxy` auth is folded back into the URL userinfo); +`httpx.Proxy` extras pyqwest can't express — custom headers, an +`ssl_context` — raise `InvalidArgumentException` rather than being silently +dropped. envd traffic (sandbox commands, filesystem, PTY, file transfers) is not affected and stays on its existing stack. diff --git a/packages/python-sdk/e2b/api/__init__.py b/packages/python-sdk/e2b/api/__init__.py index 6859ef177b..3c44710abf 100644 --- a/packages/python-sdk/e2b/api/__init__.py +++ b/packages/python-sdk/e2b/api/__init__.py @@ -81,15 +81,31 @@ async def on_response(response: Response) -> None: def proxy_to_url(proxy: Optional[ProxyTypes]) -> Optional[str]: """Narrow the ``proxy`` connection option to the proxy URL string pyqwest transports take (scheme http, https, socks5, or socks5h, credentials in - the URL userinfo). The richer ``httpx.Proxy`` objects the httpx transports - accepted (per-proxy auth, headers, TLS context) are rejected rather than - partially honored.""" + the URL userinfo). ``httpx.URL`` and ``httpx.Proxy`` — which the httpx + transports accepted — are converted when they reduce to such a URL; + ``httpx.Proxy`` extras that don't (custom headers, an ssl_context) are + rejected rather than silently dropped.""" if proxy is None: return None if isinstance(proxy, str): return proxy if isinstance(proxy, httpx.URL): return str(proxy) + if isinstance(proxy, httpx.Proxy): + if proxy.headers: + raise InvalidArgumentException( + "E2B API calls don't support httpx.Proxy custom headers; " + "pass credentials in the proxy URL instead, " + 'e.g. proxy="http://user:pass@localhost:8030"' + ) + if proxy.ssl_context is not None: + raise InvalidArgumentException( + "E2B API calls don't support httpx.Proxy ssl_context" + ) + url = proxy.url + if proxy.auth is not None: + url = url.copy_with(username=proxy.auth[0], password=proxy.auth[1]) + return str(url) raise InvalidArgumentException( "E2B API calls support only URL-string proxies, " 'e.g. proxy="http://user:pass@localhost:8030"' diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index 4bc4825678..f1e1ead285 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -1,5 +1,6 @@ import asyncio import json +import ssl import threading from concurrent.futures import ThreadPoolExecutor from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -54,8 +55,31 @@ def test_proxy_to_url_narrows_to_url_strings(): assert proxy_to_url(None) is None assert proxy_to_url("http://127.0.0.1:9999") == "http://127.0.0.1:9999" assert proxy_to_url(httpx.URL("http://127.0.0.1:9999")) == "http://127.0.0.1:9999" + + +def test_proxy_to_url_reduces_httpx_proxy(): + # httpx.Proxy converts when it reduces to a plain proxy URL, with + # credentials (which httpx.Proxy splits off the URL) folded back into + # the userinfo. + assert proxy_to_url(httpx.Proxy("http://127.0.0.1:9999")) == "http://127.0.0.1:9999" + assert ( + proxy_to_url(httpx.Proxy("http://user:pass@127.0.0.1:9999")) + == "http://user:pass@127.0.0.1:9999" + ) + assert ( + proxy_to_url(httpx.Proxy("http://127.0.0.1:9999", auth=("user", "pass"))) + == "http://user:pass@127.0.0.1:9999" + ) + + # Extras pyqwest can't express are rejected rather than silently dropped. + with pytest.raises(InvalidArgumentException): + proxy_to_url(httpx.Proxy("http://127.0.0.1:9999", headers={"X-Auth": "t"})) with pytest.raises(InvalidArgumentException): - proxy_to_url(httpx.Proxy("http://127.0.0.1:9999")) + proxy_to_url( + httpx.Proxy( + "https://127.0.0.1:9999", ssl_context=ssl.create_default_context() + ) + ) def test_connection_retry_policy_retries_only_connection_errors(): From d22b89ec6d4078fc0bc1fe39c96adcff3e0f0808 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:30:21 +0200 Subject: [PATCH 05/10] docs(python-sdk): scope the changeset to the control-plane API client envd RPC already runs on pyqwest via connectrpc (#1558); the envd HTTP API migration is stacked separately. Co-Authored-By: Claude Fable 5 --- .changeset/python-pyqwest-rest-transport.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/python-pyqwest-rest-transport.md b/.changeset/python-pyqwest-rest-transport.md index 6117259300..b425b9bcab 100644 --- a/.changeset/python-pyqwest-rest-transport.md +++ b/.changeset/python-pyqwest-rest-transport.md @@ -26,5 +26,6 @@ such a URL (`httpx.Proxy` auth is folded back into the URL userinfo); `ssl_context` — raise `InvalidArgumentException` rather than being silently dropped. -envd traffic (sandbox commands, filesystem, PTY, file transfers) is not -affected and stays on its existing stack. +envd traffic is not affected: RPC (commands, PTY, filesystem watch) already +runs on pyqwest via `connectrpc`, and the envd HTTP API (file transfers, +health checks) keeps its httpx transports. From f1ce688d9872a1a0d6bfcb14173d0c291cf1305c Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:37:10 +0200 Subject: [PATCH 06/10] feat(python-sdk): keep httpx.ReadTimeout as the API timeout exception pyqwest raises the builtin TimeoutError for its transport timeouts, which would break callers catching httpx.TimeoutException; the adapter subclasses re-raise it as httpx.ReadTimeout. Co-Authored-By: Claude Fable 5 --- .changeset/python-pyqwest-rest-transport.md | 3 +- .../e2b/api/client_async/__init__.py | 17 ++++++-- .../e2b/api/client_sync/__init__.py | 15 +++++-- .../tests/test_api_client_transport.py | 39 ++++++++++++++++++- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/.changeset/python-pyqwest-rest-transport.md b/.changeset/python-pyqwest-rest-transport.md index b425b9bcab..71de48c45b 100644 --- a/.changeset/python-pyqwest-rest-transport.md +++ b/.changeset/python-pyqwest-rest-transport.md @@ -16,7 +16,8 @@ proxy, instead of one pool per thread (sync) or per event loop (async), and single httpx client serves all threads and event loops. Connection-establishment failures are retried with backoff (`E2B_CONNECTION_RETRIES`, default 3), matching the connect-only retries of -the previous transports. +the previous transports. Timeouts keep raising `httpx.ReadTimeout` (an +`httpx.TimeoutException`), as before. `proxy` for API calls now takes a URL string (e.g. `proxy="http://user:pass@localhost:8030"`, scheme http, https, socks5, or diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index 674742283e..3fc5bea64c 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -28,17 +28,28 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient: class AsyncApiPyqwestTransport(AsyncPyqwestTransport): - """Strip the ``Host`` header httpx adds to every request: hyper derives + """The SDK's tweaks on the stock pyqwest httpx adapter. + + Strip the ``Host`` header httpx adds to every request: hyper derives HTTP/1 ``Host`` and HTTP/2 ``:authority`` from the URL itself, and forwarding an explicit ``host`` header on an HTTP/2 connection makes the E2B API edge reset the stream with PROTOCOL_ERROR. (Custom ``Host`` overrides are therefore not honored, matching hyper's URL-derived - behavior.)""" + behavior.) + + Re-raise pyqwest's timeouts (the builtin ``TimeoutError``, and + ``asyncio.TimeoutError`` from the adapter's deadline — distinct types + until Python 3.11) as ``httpx.ReadTimeout``, preserving the + ``httpx.TimeoutException`` contract the httpx-native transport gave + callers.""" async def handle_async_request(self, request: httpx.Request) -> httpx.Response: if "host" in request.headers: del request.headers["host"] - return await super().handle_async_request(request) + try: + return await super().handle_async_request(request) + except (TimeoutError, asyncio.TimeoutError) as e: + raise httpx.ReadTimeout(str(e), request=request) from e class ConnectionRetryTransport(RetryTransport): diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index 7c68a7a3b7..ab0104aba2 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -26,17 +26,26 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient: class ApiPyqwestTransport(PyqwestTransport): - """Strip the ``Host`` header httpx adds to every request: hyper derives + """The SDK's tweaks on the stock pyqwest httpx adapter. + + Strip the ``Host`` header httpx adds to every request: hyper derives HTTP/1 ``Host`` and HTTP/2 ``:authority`` from the URL itself, and forwarding an explicit ``host`` header on an HTTP/2 connection makes the E2B API edge reset the stream with PROTOCOL_ERROR. (Custom ``Host`` overrides are therefore not honored, matching hyper's URL-derived - behavior.)""" + behavior.) + + Re-raise pyqwest's timeouts (the builtin ``TimeoutError``) as + ``httpx.ReadTimeout``, preserving the ``httpx.TimeoutException`` contract + the httpx-native transport gave callers.""" def handle_request(self, request: httpx.Request) -> httpx.Response: if "host" in request.headers: del request.headers["host"] - return super().handle_request(request) + try: + return super().handle_request(request) + except TimeoutError as e: + raise httpx.ReadTimeout(str(e), request=request) from e class ConnectionRetryTransport(SyncRetryTransport): diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index f1e1ead285..78ff5c5a8e 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -2,6 +2,7 @@ import json import ssl import threading +import time from concurrent.futures import ThreadPoolExecutor from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import cast @@ -364,9 +365,12 @@ async def test_async_envd_transport_uses_separate_stack(test_api_key): class _EchoHandler(BaseHTTPRequestHandler): - """Answers every GET with a JSON echo of the request headers.""" + """Answers every GET with a JSON echo of the request headers; a path + starting with ``/slow`` sleeps 5 seconds first.""" def do_GET(self): + if self.path.startswith("/slow"): + time.sleep(5) headers = {k.lower(): v for k, v in self.headers.items()} body = json.dumps({"path": self.path, "headers": headers}).encode() self.send_response(200) @@ -469,3 +473,36 @@ async def test_async_api_client_round_trips_through_pyqwest(test_api_key, echo_s finally: await httpx_client.aclose() reset_async_api_transports() + + +def test_sync_api_client_timeout_raises_httpx_read_timeout(test_api_key, echo_server): + # pyqwest raises the builtin TimeoutError; the transport re-raises it as + # httpx.ReadTimeout to keep the httpx.TimeoutException contract. + reset_sync_api_transports() + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) + api_client = get_sync_api_client(config) + httpx_client = api_client.get_httpx_client() + + try: + with pytest.raises(httpx.ReadTimeout): + httpx_client.request("GET", "/slow", timeout=0.2) + finally: + httpx_client.close() + reset_sync_api_transports() + + +@pytest.mark.asyncio +async def test_async_api_client_timeout_raises_httpx_read_timeout( + test_api_key, echo_server +): + reset_async_api_transports() + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) + api_client = get_async_api_client(config) + httpx_client = api_client.get_async_httpx_client() + + try: + with pytest.raises(httpx.ReadTimeout): + await httpx_client.request("GET", "/slow", timeout=0.2) + finally: + await httpx_client.aclose() + reset_async_api_transports() From 12a2e7f2ac27a76d4363020c967c20ab9e8de99d Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:49:43 +0200 Subject: [PATCH 07/10] 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 --- .changeset/python-pyqwest-rest-transport.md | 26 +++- packages/python-sdk/e2b/api/__init__.py | 58 +++++---- .../e2b/api/client_async/__init__.py | 20 ++- .../e2b/api/client_sync/__init__.py | 20 ++- packages/python-sdk/pyproject.toml | 2 +- .../tests/test_api_client_transport.py | 121 ++++++++++++++---- 6 files changed, 181 insertions(+), 66 deletions(-) diff --git a/.changeset/python-pyqwest-rest-transport.md b/.changeset/python-pyqwest-rest-transport.md index 71de48c45b..1909dae142 100644 --- a/.changeset/python-pyqwest-rest-transport.md +++ b/.changeset/python-pyqwest-rest-transport.md @@ -19,13 +19,27 @@ Connection-establishment failures are retried with backoff the previous transports. Timeouts keep raising `httpx.ReadTimeout` (an `httpx.TimeoutException`), as before. -`proxy` for API calls now takes a URL string (e.g. +`proxy` for API calls takes a URL string (e.g. `proxy="http://user:pass@localhost:8030"`, scheme http, https, socks5, or -socks5h). `httpx.URL` and `httpx.Proxy` keep working when they reduce to -such a URL (`httpx.Proxy` auth is folded back into the URL userinfo); -`httpx.Proxy` extras pyqwest can't express — custom headers, an -`ssl_context` — raise `InvalidArgumentException` rather than being silently -dropped. +socks5h), an `httpx.URL`, or an `httpx.Proxy` — including its credentials +(sent as `Proxy-Authorization`) and any headers configured for the proxy. The +one `httpx.Proxy` option pyqwest cannot express, a per-proxy `ssl_context`, +raises `InvalidArgumentException` rather than being silently dropped. + +Low-level HTTP logs stay available: where enabling the `httpcore` logger used +to show connection-level detail, pyqwest logs one line per request on the +`pyqwest.access` logger and request lifecycle records on `pyqwest`, both at +`DEBUG` and off unless enabled: + +```python +import logging + +logging.basicConfig() +logging.getLogger("pyqwest.access").setLevel(logging.DEBUG) +# DEBUG pyqwest.access - HTTP Request: POST https://api.e2b.app/sandboxes "HTTP/2 201 Created" +``` + +The SDK's own `logger` option is unchanged and independent of these. envd traffic is not affected: RPC (commands, PTY, filesystem watch) already runs on pyqwest via `connectrpc`, and the envd HTTP API (file transfers, diff --git a/packages/python-sdk/e2b/api/__init__.py b/packages/python-sdk/e2b/api/__init__.py index 3c44710abf..d9dcce0a4b 100644 --- a/packages/python-sdk/e2b/api/__init__.py +++ b/packages/python-sdk/e2b/api/__init__.py @@ -4,11 +4,12 @@ import re from dataclasses import dataclass from types import TracebackType -from typing import Optional, Protocol, Union +from typing import NamedTuple, Optional, Protocol, Tuple, Union import httpx from httpx import AsyncBaseTransport, BaseTransport, Limits, Timeout from httpx._types import ProxyTypes +from pyqwest import Proxy from e2b.api.client.client import AuthenticatedClient from e2b.api.client.types import Response @@ -78,37 +79,50 @@ async def on_response(response: Response) -> None: pool_max_idle_per_host = int(os.getenv("E2B_MAX_KEEPALIVE_CONNECTIONS") or "20") -def proxy_to_url(proxy: Optional[ProxyTypes]) -> Optional[str]: - """Narrow the ``proxy`` connection option to the proxy URL string pyqwest - transports take (scheme http, https, socks5, or socks5h, credentials in - the URL userinfo). ``httpx.URL`` and ``httpx.Proxy`` — which the httpx - transports accepted — are converted when they reduce to such a URL; - ``httpx.Proxy`` extras that don't (custom headers, an ssl_context) are - rejected rather than silently dropped.""" +class ProxyConfig(NamedTuple): + """The ``proxy`` connection option in the shape pyqwest transports take. + + A tuple so it can key the transport caches directly: it is hashable and + compares by value, where a ``pyqwest.Proxy`` compares by identity and + would hand every call its own connection pool.""" + + url: str + auth: Optional[Tuple[str, str]] = None + headers: Tuple[Tuple[str, str], ...] = () + + def to_pyqwest(self) -> Proxy: + """The ``pyqwest.Proxy`` to hand a transport.""" + return Proxy(self.url, auth=self.auth, headers=self.headers or None) + + +def proxy_to_config(proxy: Optional[ProxyTypes]) -> Optional[ProxyConfig]: + """Convert the ``proxy`` connection option — a URL string, an + ``httpx.URL``, or an ``httpx.Proxy`` — to the proxy configuration pyqwest + transports take: a proxy URL (scheme http, https, socks5, or socks5h, + credentials allowed in the userinfo), basic-auth credentials, and headers + to send to the proxy. An ``httpx.Proxy`` ``ssl_context`` has no pyqwest + counterpart and is rejected rather than silently dropped.""" if proxy is None: return None if isinstance(proxy, str): - return proxy + return ProxyConfig(proxy) if isinstance(proxy, httpx.URL): - return str(proxy) + return ProxyConfig(str(proxy)) if isinstance(proxy, httpx.Proxy): - if proxy.headers: - raise InvalidArgumentException( - "E2B API calls don't support httpx.Proxy custom headers; " - "pass credentials in the proxy URL instead, " - 'e.g. proxy="http://user:pass@localhost:8030"' - ) if proxy.ssl_context is not None: raise InvalidArgumentException( "E2B API calls don't support httpx.Proxy ssl_context" ) - url = proxy.url - if proxy.auth is not None: - url = url.copy_with(username=proxy.auth[0], password=proxy.auth[1]) - return str(url) + # httpx.Proxy splits userinfo out of the URL into `.auth`; pyqwest + # takes the credentials the same way, so they pass straight through. + return ProxyConfig( + str(proxy.url), + auth=proxy.auth, + headers=tuple(proxy.headers.items()), + ) raise InvalidArgumentException( - "E2B API calls support only URL-string proxies, " - 'e.g. proxy="http://user:pass@localhost:8030"' + "E2B API calls support only URL-string, httpx.URL, and httpx.Proxy " + 'proxies, e.g. proxy="http://user:pass@localhost:8030"' ) diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index 3fc5bea64c..2d8d58cfd2 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -12,11 +12,12 @@ from e2b.api import ( AsyncApiClient, + ProxyConfig, connection_retries, limits, pool_idle_timeout, pool_max_idle_per_host, - proxy_to_url, + proxy_to_config, ) from e2b.connection_config import ConnectionConfig @@ -71,29 +72,34 @@ def should_retry_response( # pyqwest's I/O runs on its own Rust runtime, so unlike the httpx envd # transports below, the transport is not bound to an event loop and the # cache is process-global rather than per-loop. -_transports: Dict[Optional[str], "AsyncApiPyqwestTransport"] = {} +_transports: Dict[Optional[ProxyConfig], "AsyncApiPyqwestTransport"] = {} def get_transport(config: ConnectionConfig) -> "AsyncApiPyqwestTransport": """The shared pyqwest-backed httpx transport for REST API calls. For TLS connections ALPN negotiates the HTTP version (HTTP/2 against the E2B - API), like the http2-enabled httpx transport this replaced.""" - proxy_url = proxy_to_url(config.proxy) + API), like the http2-enabled httpx transport this replaced. + + Requests are logged by pyqwest itself on the ``pyqwest.access`` and + ``pyqwest`` loggers at ``DEBUG`` (off unless enabled) — the transport-level + diagnostics httpcore used to provide. The SDK's own ``logger`` option is + separate and sits above this, on the httpx client.""" + proxy = proxy_to_config(config.proxy) with _transport_lock: - transport = _transports.get(proxy_url) + transport = _transports.get(proxy) if transport is None: transport = AsyncApiPyqwestTransport( ConnectionRetryTransport( HTTPTransport( tls_include_system_certs=True, - proxy=proxy_url, + proxy=proxy.to_pyqwest() if proxy is not None else None, pool_idle_timeout=pool_idle_timeout, pool_max_idle_per_host=pool_max_idle_per_host, ), max_retries=connection_retries, ) ) - _transports[proxy_url] = transport + _transports[proxy] = transport return transport diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index ab0104aba2..a087aa6138 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -10,11 +10,12 @@ from e2b.api import ( ApiClient, + ProxyConfig, connection_retries, limits, pool_idle_timeout, pool_max_idle_per_host, - proxy_to_url, + proxy_to_config, ) from e2b.connection_config import ConnectionConfig @@ -66,29 +67,34 @@ def should_retry_response( # One transport (= one connection pool) per proxy; None is the direct pool. # pyqwest transports are thread-safe, so unlike the httpx envd transports # below, the cache is process-global rather than per-thread. -_transports: Dict[Optional[str], "ApiPyqwestTransport"] = {} +_transports: Dict[Optional[ProxyConfig], "ApiPyqwestTransport"] = {} def get_transport(config: ConnectionConfig) -> "ApiPyqwestTransport": """The shared pyqwest-backed httpx transport for REST API calls. For TLS connections ALPN negotiates the HTTP version (HTTP/2 against the E2B - API), like the http2-enabled httpx transport this replaced.""" - proxy_url = proxy_to_url(config.proxy) + API), like the http2-enabled httpx transport this replaced. + + Requests are logged by pyqwest itself on the ``pyqwest.access`` and + ``pyqwest`` loggers at ``DEBUG`` (off unless enabled) — the transport-level + diagnostics httpcore used to provide. The SDK's own ``logger`` option is + separate and sits above this, on the httpx client.""" + proxy = proxy_to_config(config.proxy) with _transport_lock: - transport = _transports.get(proxy_url) + transport = _transports.get(proxy) if transport is None: transport = ApiPyqwestTransport( ConnectionRetryTransport( SyncHTTPTransport( tls_include_system_certs=True, - proxy=proxy_url, + proxy=proxy.to_pyqwest() if proxy is not None else None, pool_idle_timeout=pool_idle_timeout, pool_max_idle_per_host=pool_max_idle_per_host, ), max_retries=connection_retries, ) ) - _transports[proxy_url] = transport + _transports[proxy] = transport return transport diff --git a/packages/python-sdk/pyproject.toml b/packages/python-sdk/pyproject.toml index 98ea4f4738..5e022c6e98 100644 --- a/packages/python-sdk/pyproject.toml +++ b/packages/python-sdk/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "dockerfile-parse>=2.0.1,<3", "rich>=14.0.0", "connectrpc>=0.11.1,<0.12", - "pyqwest>=0.7.0,<0.8", + "pyqwest>=0.8.0,<0.9", ] [project.urls] diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index 78ff5c5a8e..3c08885e18 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -1,5 +1,7 @@ import asyncio +import base64 import json +import logging import ssl import threading import time @@ -9,8 +11,10 @@ import httpx import pytest +from httpx._types import ProxyTypes from pyqwest import ( Headers, + Proxy, Request, Response, SyncRequest, @@ -22,7 +26,7 @@ import e2b.api.client_async as client_async import e2b.api.client_sync as client_sync -from e2b.api import proxy_to_url +from e2b.api import ProxyConfig, proxy_to_config from e2b.api.client_async import AsyncEnvdTransportWithLogger from e2b.api.client_async import get_api_client as get_async_api_client from e2b.api.client_async import get_envd_transport as get_async_envd_transport @@ -52,37 +56,58 @@ def run_in_worker_thread(fn): return executor.submit(fn).result() -def test_proxy_to_url_narrows_to_url_strings(): - assert proxy_to_url(None) is None - assert proxy_to_url("http://127.0.0.1:9999") == "http://127.0.0.1:9999" - assert proxy_to_url(httpx.URL("http://127.0.0.1:9999")) == "http://127.0.0.1:9999" - - -def test_proxy_to_url_reduces_httpx_proxy(): - # httpx.Proxy converts when it reduces to a plain proxy URL, with - # credentials (which httpx.Proxy splits off the URL) folded back into - # the userinfo. - assert proxy_to_url(httpx.Proxy("http://127.0.0.1:9999")) == "http://127.0.0.1:9999" - assert ( - proxy_to_url(httpx.Proxy("http://user:pass@127.0.0.1:9999")) - == "http://user:pass@127.0.0.1:9999" +def test_proxy_to_config_narrows_urls(): + assert proxy_to_config(None) is None + assert proxy_to_config("http://127.0.0.1:9999") == ProxyConfig( + "http://127.0.0.1:9999" ) - assert ( - proxy_to_url(httpx.Proxy("http://127.0.0.1:9999", auth=("user", "pass"))) - == "http://user:pass@127.0.0.1:9999" + assert proxy_to_config(httpx.URL("http://127.0.0.1:9999")) == ProxyConfig( + "http://127.0.0.1:9999" ) + # Guards callers that ignore the type hint. + with pytest.raises(InvalidArgumentException, match="URL-string"): + proxy_to_config(cast(ProxyTypes, object())) - # Extras pyqwest can't express are rejected rather than silently dropped. - with pytest.raises(InvalidArgumentException): - proxy_to_url(httpx.Proxy("http://127.0.0.1:9999", headers={"X-Auth": "t"})) - with pytest.raises(InvalidArgumentException): - proxy_to_url( + +def test_proxy_to_config_converts_httpx_proxy(): + # Everything pyqwest's Proxy can express carries over: the URL, the + # credentials httpx.Proxy splits off the URL, and headers for the proxy. + assert proxy_to_config(httpx.Proxy("http://127.0.0.1:9999")) == ProxyConfig( + "http://127.0.0.1:9999" + ) + assert proxy_to_config( + httpx.Proxy("http://user:pass@127.0.0.1:9999") + ) == ProxyConfig("http://127.0.0.1:9999", auth=("user", "pass")) + assert proxy_to_config( + httpx.Proxy("http://127.0.0.1:9999", auth=("user@x", "p@ss")) + ) == ProxyConfig("http://127.0.0.1:9999", auth=("user@x", "p@ss")) + assert proxy_to_config( + httpx.Proxy("http://127.0.0.1:9999", headers={"X-Auth": "t"}) + ) == ProxyConfig("http://127.0.0.1:9999", headers=(("x-auth", "t"),)) + + # A per-proxy TLS context has no pyqwest counterpart; rejected rather than + # silently dropped. + with pytest.raises(InvalidArgumentException, match="ssl_context"): + proxy_to_config( httpx.Proxy( "https://127.0.0.1:9999", ssl_context=ssl.create_default_context() ) ) +def test_proxy_config_builds_a_pyqwest_proxy(): + config = ProxyConfig( + "http://127.0.0.1:9999", auth=("user", "pass"), headers=(("x-auth", "t"),) + ) + assert isinstance(config.to_pyqwest(), Proxy) + # Equal configs are one cache key, even though the Proxy objects they + # build compare by identity. + assert config == ProxyConfig( + "http://127.0.0.1:9999", auth=("user", "pass"), headers=(("x-auth", "t"),) + ) + assert config.to_pyqwest() != config.to_pyqwest() + + def test_connection_retry_policy_retries_only_connection_errors(): # The inner transport is never invoked by should_retry_response. sync_policy = client_sync.ConnectionRetryTransport(cast(SyncTransport, object())) @@ -395,6 +420,56 @@ def echo_server(): thread.join() +def test_sync_transport_sends_proxy_credentials_and_headers(test_api_key, echo_server): + # Everything an httpx.Proxy can express reaches the proxy: the echo server + # stands in for one, so the request arrives in absolute form with the + # credentials and the extra headers configured for it. + reset_sync_api_transports() + config = ConnectionConfig( + api_key=test_api_key, + proxy=httpx.Proxy( + echo_server, auth=("user", "pass"), headers={"X-Proxy-Token": "t"} + ), + ) + client = httpx.Client(transport=get_sync_transport(config)) + + try: + echoed = client.get("http://proxied.invalid/health").json() + assert echoed["path"] == "http://proxied.invalid/health" + assert echoed["headers"]["proxy-authorization"] == ( + "Basic " + base64.b64encode(b"user:pass").decode() + ) + assert echoed["headers"]["x-proxy-token"] == "t" + finally: + client.close() + reset_sync_api_transports() + + +def test_transport_emits_pyqwest_access_log(test_api_key, echo_server, caplog): + # pyqwest logs every request on `pyqwest.access` at DEBUG — the + # transport-level diagnostics httpcore used to provide, and separate from + # the SDK's own `logger` option. + reset_sync_api_transports() + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) + api_client = get_sync_api_client(config) + httpx_client = api_client.get_httpx_client() + + try: + with caplog.at_level(logging.DEBUG, logger="pyqwest.access"): + assert httpx_client.request("GET", "/sandboxes").status_code == 200 + + messages = [ + r.getMessage() for r in caplog.records if r.name == "pyqwest.access" + ] + # The stdlib test server answers HTTP/1.0. + assert messages == [ + f'HTTP Request: GET {echo_server}/sandboxes "HTTP/1.0 200 OK"' + ] + finally: + httpx_client.close() + reset_sync_api_transports() + + def test_sync_api_client_round_trips_through_pyqwest(test_api_key, echo_server): reset_sync_api_transports() config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) From f8020ea87fb64d8e6ca77e47ac3e03b0b9e32eb4 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:28:36 +0200 Subject: [PATCH 08/10] chore(python-sdk): lock pyqwest 0.8.0 The branch's pyproject already requires `pyqwest>=0.8.0,<0.9` for the Proxy object and the multipart fix; refresh the lockfile now that 0.8.0 is on PyPI. Co-Authored-By: Claude Opus 5 (1M context) --- packages/python-sdk/uv.lock | 92 ++++++++++++++++++------------------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/packages/python-sdk/uv.lock b/packages/python-sdk/uv.lock index 7ea6e0def3..e83ec3baf0 100644 --- a/packages/python-sdk/uv.lock +++ b/packages/python-sdk/uv.lock @@ -228,7 +228,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0,<1.0.0" }, { name = "packaging", specifier = ">=24.1" }, { name = "protobuf-py", specifier = ">=0.1.1,<0.2" }, - { name = "pyqwest", specifier = ">=0.7.0,<0.8" }, + { name = "pyqwest", specifier = ">=0.8.0,<0.9" }, { name = "python-dateutil", specifier = ">=2.8.2" }, { name = "rich", specifier = ">=14.0.0" }, { name = "typing-extensions", specifier = ">=4.10.0" }, @@ -811,55 +811,55 @@ wheels = [ [[package]] name = "pyqwest" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/01/05/8576f0eeb44d3fe14c70e2bafa77ae6930c5e6f42b93c568719ea4456715/pyqwest-0.7.0.tar.gz", hash = "sha256:ac65f2243f3e814e7f4aad3f2fcfe78f89aad2de2a825eaaabf4c102f1937843", size = 457991, upload-time = "2026-07-19T05:20:06.61Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/86/49/7f26a2c2a3e8bbab4862bcbf4f54b706d9524fa9705cc0127553b81ed161/pyqwest-0.7.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7b2339f3b55ae179e0cb55bbd5792ae1c954d31f80de7b4dde0e95b03576b6be", size = 5159904, upload-time = "2026-07-19T05:18:57.757Z" }, - { url = "https://files.pythonhosted.org/packages/20/f1/4aba1976566936ca873e1a726a0d9bdf8195a0dc4e4f78122f6050328566/pyqwest-0.7.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:2159c7e2eaf2563cdadfda17314e2b1747c30603979bf73db8daef311247db82", size = 5044476, upload-time = "2026-07-19T05:18:59.473Z" }, - { url = "https://files.pythonhosted.org/packages/fe/06/45908e5cda7fa28ba60df5ed2091056893c394c3217a796bc6577c4bd294/pyqwest-0.7.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a826f1b492a7497f469c8467bd51faf582733654bb2bc9fcdd4fa502f3e6ca1", size = 5560021, upload-time = "2026-07-19T05:19:01.097Z" }, - { url = "https://files.pythonhosted.org/packages/f2/3d/603f5c9446e8c13a4970b816246d7928e895408a7ed1778d98de3e25ea43/pyqwest-0.7.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da43c7e86bee9e74ff474d4829cd398fb9220ff0d84d633094ea6797fdfe03a1", size = 5506130, upload-time = "2026-07-19T05:19:02.812Z" }, - { url = "https://files.pythonhosted.org/packages/33/6d/2984aa02f4d0296dddb5df159e1af6c6e0ddc0b507d592f13d6ccd0b3162/pyqwest-0.7.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d1c71327c323a19dc90a0dafb1d68fb13f4f775a2498a26bf95f17165e64da9f", size = 5719961, upload-time = "2026-07-19T05:19:04.213Z" }, - { url = "https://files.pythonhosted.org/packages/19/06/e30c0d31eea17cabf99ef90c7c02e14cc94ca7d59793d397de9359148693/pyqwest-0.7.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c002e0fc31a96f1c47986299710f49ed4551e4a912a7cdb69aba6fd94db6d544", size = 5908279, upload-time = "2026-07-19T05:19:05.595Z" }, - { url = "https://files.pythonhosted.org/packages/eb/db/a1368faae1cbd094a0d179b76de69b8d1908bd45898d3bcd29ac98622072/pyqwest-0.7.0-cp310-abi3-win_amd64.whl", hash = "sha256:847e4468b5379a219b91a13dd3c92dd3b7b3d9f59af23e4c6776e663594cb241", size = 4755104, upload-time = "2026-07-19T05:19:07.772Z" }, - { url = "https://files.pythonhosted.org/packages/b3/38/c493e85ebd17d3a5c56eb53fbea36a1a6f396b999b38173ffde513576eb1/pyqwest-0.7.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:4988fa1c368072886dce48f52fcbabe9f25784c6e189a9a6f2b42f6e9f383973", size = 5172962, upload-time = "2026-07-19T05:19:09.229Z" }, - { url = "https://files.pythonhosted.org/packages/d6/68/1b340fdc7735b5682d308cabc2d65ea76c03a6cc2c30072a0c14c24f716c/pyqwest-0.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dfb61138c802c7317d840a274fa24f9d878301f693268e9186a9896452017a71", size = 5032604, upload-time = "2026-07-19T05:19:10.884Z" }, - { url = "https://files.pythonhosted.org/packages/4d/52/eecd858568ef6586cbdc3c419a9a0b1e8f891e2968f0f6b2a9fa9bdaac19/pyqwest-0.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6fdc0601590bdc547007828627082f0dc0e445e92d900dccb227e51898d7243", size = 5558302, upload-time = "2026-07-19T05:19:12.348Z" }, - { url = "https://files.pythonhosted.org/packages/c0/8f/b67d0056b18a9f99a1c9253cb983d5c4361e3e102d729df6623b71d6d939/pyqwest-0.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f82c971810695f5fd7962859a2469616c83b7aca6664d8f147287ee5ff430cd", size = 5501483, upload-time = "2026-07-19T05:19:13.892Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a6/69b87c4c80ced01645a143679458895dcb5c0e5ca0b3d4a43d979939cce6/pyqwest-0.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9dc2f405803b94525ab030c01cdac7093bcc1a5da6802de3345a868a0f51b3da", size = 5717334, upload-time = "2026-07-19T05:19:15.541Z" }, - { url = "https://files.pythonhosted.org/packages/26/7b/5891b72b9193b692b50ecdc4e26e7e3687f9763f5f263646bc8f997f5b62/pyqwest-0.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:537f71f97a533fa355d02c15ed9f0cc05fb8915996cb921caa87fe7310b457ee", size = 5902447, upload-time = "2026-07-19T05:19:17.273Z" }, - { url = "https://files.pythonhosted.org/packages/6a/80/1b280618af3f9d36081449d153efc5620f0eea93ac169574fc69208d2b91/pyqwest-0.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:196aa73fb7eee4b6c052d6f4172a4321904a7208331f4322ccee3ee7d0adbc78", size = 4750469, upload-time = "2026-07-19T05:19:18.916Z" }, - { url = "https://files.pythonhosted.org/packages/c1/9c/64c8df83a152804ac3074fb879c1229c3aca55a12dc33dc73c72e93405e6/pyqwest-0.7.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:72d001892ed570df1dcc489ef8b0a03bc7abd4fbdca10625c358a87e66b226ad", size = 5171308, upload-time = "2026-07-19T05:19:20.332Z" }, - { url = "https://files.pythonhosted.org/packages/14/d1/46e629541a3f7231dd978fa9939e6ddcd075238880395bcfb16544769165/pyqwest-0.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:03da0797bac5c2eb1a40f02afccd4b5aad5ad0d375bb993df2f5e0c692fc0c6c", size = 5032449, upload-time = "2026-07-19T05:19:21.967Z" }, - { url = "https://files.pythonhosted.org/packages/44/3c/132194c747696e41ad5745c51587bd20057d800b6a64fb901ce2ac990149/pyqwest-0.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:837e18aef4490eed25b4194d49f16732ccf1abf2cdd0845e75d75d2a4428b98c", size = 5556847, upload-time = "2026-07-19T05:19:23.589Z" }, - { url = "https://files.pythonhosted.org/packages/e6/5d/6726a70e89c60bf3cd30d5d918eff68dc1d1ceb9c911331d7fb57977bfc3/pyqwest-0.7.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:418e8c3a67ca6226bfb6147b8668203c74d9c872657117086f4c264a674d7980", size = 5500333, upload-time = "2026-07-19T05:19:25.317Z" }, - { url = "https://files.pythonhosted.org/packages/49/3f/329df64d3e8778bc311a22e432280cd317619c3f9b7e414f375127bb90b0/pyqwest-0.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31176abbbcbe6d03740967b5c0241deaa60b328792644cb2e317a34d6b076433", size = 5717248, upload-time = "2026-07-19T05:19:26.963Z" }, - { url = "https://files.pythonhosted.org/packages/2e/93/6670afb97c6ce7b5ba27981e023d9edc7413e509bbe18afebb729834d9e6/pyqwest-0.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e21102f9c13234a0066d42bb5429dc5c311d7fd99401878837d9675a7f6e03b9", size = 5902814, upload-time = "2026-07-19T05:19:28.637Z" }, - { url = "https://files.pythonhosted.org/packages/51/d3/bf9440a03c97f408743313215d8c7e9875b0b2b0d2ef97f5fbb5066cd57d/pyqwest-0.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:ca19e96b5e902a6d35e18cee7f5e90612fca6ab169378d42242cdcb638578cee", size = 4750510, upload-time = "2026-07-19T05:19:30.587Z" }, - { url = "https://files.pythonhosted.org/packages/dc/25/8b0919bf504309982857e564e3035ffd799d7aaa23457eb351485573e5ee/pyqwest-0.7.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4e4e79187c3e4ebd07d663d8dc7a7cba865c72020332e41807426a7e3526fda0", size = 5177972, upload-time = "2026-07-19T05:19:32.051Z" }, - { url = "https://files.pythonhosted.org/packages/52/12/d652213fe336c52b75fb4df710f5cff08f8a1dcc9e385c9120f18476f5c9/pyqwest-0.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc2c569481ade1bc0b89b07daf1b49b20a1337a53207ffe0ef325260f509404", size = 5032608, upload-time = "2026-07-19T05:19:33.772Z" }, - { url = "https://files.pythonhosted.org/packages/82/b0/406c91563140b87ade5c5935410997b26449b8eb2f91511bc52741ae38fb/pyqwest-0.7.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ef1bfbdca9ec9c8a7b223268f5ef8d45694da7226929454b0cb40f2e3d1ddd5", size = 5558937, upload-time = "2026-07-19T05:19:35.174Z" }, - { url = "https://files.pythonhosted.org/packages/7f/17/a0a09936b5dd5d9d7517289aee3683bbf6554b0853bce5280ead9da27336/pyqwest-0.7.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4c2f6a6830becfa1c5bb94aa169ad854da5749a95a440aee759932d5965c213", size = 5500603, upload-time = "2026-07-19T05:19:37.023Z" }, - { url = "https://files.pythonhosted.org/packages/00/34/ccec52ca9f14fbe11292fadbe89c771353e0f46d58aa88d8e93d6e018117/pyqwest-0.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0d11128ccc382f64fcdf92d3bce6be7191489c1b3a9a3f0dbdf89e55451638de", size = 5719784, upload-time = "2026-07-19T05:19:38.649Z" }, - { url = "https://files.pythonhosted.org/packages/16/cc/58dca466c236e497cbb2e75b97a2489225d21f5063d932ab7d1723294112/pyqwest-0.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5832373c7bcccfbc3bb79c44b592e0871631139a2ef5aef771b8fe2b7bd4301d", size = 5901396, upload-time = "2026-07-19T05:19:40.402Z" }, - { url = "https://files.pythonhosted.org/packages/06/a0/983ea3b859828bef8372896e9c407f62bf426b84526d0b0cf9fd9abce411/pyqwest-0.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:7330070c4497e564007985716ac347cb9a7500eba5c9610500653a2ce4cfe86e", size = 4751036, upload-time = "2026-07-19T05:19:42.159Z" }, - { url = "https://files.pythonhosted.org/packages/92/1d/e2fe3dfe6d87989017412f394abc45435a3af2526e998c83cf836937b23c/pyqwest-0.7.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:02fd606a35be7803a770071f642a375e55df4c2025e49b10b859430f424c4492", size = 5165801, upload-time = "2026-07-19T05:19:43.82Z" }, - { url = "https://files.pythonhosted.org/packages/f4/fe/90126ac71fe09c729f36c166cb71b5148dd8a80e47f6f232bf71494604a2/pyqwest-0.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a807835c6a0777f0cc57c321c78c7bf162f6354698844d78db6e03ba9edd83dc", size = 5025128, upload-time = "2026-07-19T05:19:45.391Z" }, - { url = "https://files.pythonhosted.org/packages/e5/15/ee84ce834705d613c0b113fb4cf9d274011cfcc6c6ededb52d92a38a2367/pyqwest-0.7.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86d84871cb0a572700dece3a6c45c294721f655f3d0b85477333209b487ecef1", size = 5551017, upload-time = "2026-07-19T05:19:47.046Z" }, - { url = "https://files.pythonhosted.org/packages/41/ad/18b4540fb276f9b540018efe024aca0801acfe4209417d4faed85bccdb04/pyqwest-0.7.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:005ddd2a777d30ca7ce4de12df1336760e14d46394ab56299a9e81626d78b991", size = 5493032, upload-time = "2026-07-19T05:19:49.038Z" }, - { url = "https://files.pythonhosted.org/packages/6c/91/f25477eb80c375378a876cf2bac80c55ec9ea36b94bd7d4b2b2931860c85/pyqwest-0.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca02089249c292ab9c148fa86c06927701897090226a13e392a6ec53312380ea", size = 5712503, upload-time = "2026-07-19T05:19:50.542Z" }, - { url = "https://files.pythonhosted.org/packages/f4/63/c5be988c55c69c87de950e3dabab322f82a78dc7c2ebdf89af32626bd473/pyqwest-0.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:da3bc117c1380e9577994da15b8236f7c3bb400111d6be4b57a9b2e4f30c9a79", size = 5893782, upload-time = "2026-07-19T05:19:52.286Z" }, - { url = "https://files.pythonhosted.org/packages/82/a7/96144e6db9a49eb5e42734562ac5d387c2b78fe1142674d3a284ce188ef7/pyqwest-0.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a599ddac7ded32ed62d15ca90bc9c77652ba34b225cf17a404821cec92a189fa", size = 4744187, upload-time = "2026-07-19T05:19:53.921Z" }, - { url = "https://files.pythonhosted.org/packages/ed/42/26cf547b2e43078b4663e717715a289f31fc873d65d45cf354f364faa744/pyqwest-0.7.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:bebc51e3c9c6339d81964c6e60516a5f5c9fe793c82da57ef7c2d87a55741829", size = 5170766, upload-time = "2026-07-19T05:19:55.472Z" }, - { url = "https://files.pythonhosted.org/packages/dc/bc/1aac42f34bd3e5ac4c13a2c267dba6bda4bdea594ff96c7851dff930c8f1/pyqwest-0.7.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:37623b492aea7ccd3b6cfe3179110643d28c2cdc83c765aac3d207b4321995d2", size = 5053361, upload-time = "2026-07-19T05:19:56.973Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/e3d8cfd2c7be0e12773ee4f767efc03be50ccdd28c6155d0b176bf1d8a9a/pyqwest-0.7.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02ff96a7746de208a4b8c78abe780c413d66576146a4ec64efd348b0c8a8328c", size = 5573900, upload-time = "2026-07-19T05:19:58.536Z" }, - { url = "https://files.pythonhosted.org/packages/39/a4/7dd4d548be6d54b195d737164dc67c39d9c5df40aa76a873fe1d2d9b4426/pyqwest-0.7.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd2e9bfa81198db7023202962a94920986a9a3dd12fe2f9d310f0cfcdddc092c", size = 5510692, upload-time = "2026-07-19T05:20:00.015Z" }, - { url = "https://files.pythonhosted.org/packages/48/13/c070baf81da8653803328b632baddb5647b66cc7c6353c58f024ca82277c/pyqwest-0.7.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:e1548708dbeb29a60db199c70b9e93733d6b334e7efa1dfa5e3846a4f890db6d", size = 5735516, upload-time = "2026-07-19T05:20:01.487Z" }, - { url = "https://files.pythonhosted.org/packages/84/46/e0f2a02ebf504db6bdf4bf343f0d3362eccf8dd5a325382fab1305fa7022/pyqwest-0.7.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:4bb3cf0975ee829b6c76e1c42f01863122ce71b1a952e507e260a9d29eb9b183", size = 5916743, upload-time = "2026-07-19T05:20:03.132Z" }, - { url = "https://files.pythonhosted.org/packages/07/7c/4a35df79a142e5fcb5f46fce2d1d70dd72325a65db4139bbba4fbed0d9b9/pyqwest-0.7.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2ea1ce4d630c92cb2cc6b3bf91ada0053b051dfd5c0662d89957ebd829cfdcce", size = 4759967, upload-time = "2026-07-19T05:20:04.837Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/cd/25/305acffe35817ac5dd3659f06bf5c7522cf2d6b1cbfb6e71dfd4e7cf28b0/pyqwest-0.8.0.tar.gz", hash = "sha256:138a6a01f5e3bdd92abed412f35bf122b5a7c41cadaffe9c8b3ba0dc2dc5d822", size = 469648, upload-time = "2026-07-31T03:33:42.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/38/76280a079b0f84dcc05c14eba16b6cd39bef9e8aa0a51e916abf71fea637/pyqwest-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2976d8fc93b7b4fdb9bfc7ad231e6584d81b9777ad88c8256e02cfa8b690364d", size = 5216354, upload-time = "2026-07-31T03:32:16.344Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/afde50b6814e6c053237fb6182f3ffcbe6562fdbfd0c44b6b6589cb37ea7/pyqwest-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:afe8a86b28ef637b85d2239758f91ef3acdb01bd569fb8abcb4ec70b368fd2d7", size = 5095245, upload-time = "2026-07-31T03:32:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/d9/18/0b0d177754b337afaf9b7d7b0de476ae250bab3bbf105568211fa83da2d6/pyqwest-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8d948f99dc80e1d4879bbfb4ff073e1d7566dc307d483f6857907b9b634631d", size = 5614817, upload-time = "2026-07-31T03:32:21.005Z" }, + { url = "https://files.pythonhosted.org/packages/dc/12/d5b2e201ca3c374174644fbf201329ff6635ebabef0fcc49701f8d168984/pyqwest-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bcd1edaa837e136af6982c31c17100a16b6e4918e7237a34229928754edae35", size = 5557336, upload-time = "2026-07-31T03:32:22.725Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/d5ba098c323d7f5eb9cb9ebf075060d14c3364bf176d9815655065462bc7/pyqwest-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1c25504a5fe4d72f43b17253c9e1dc4cda426e9fa0a72474c4555455f63706c1", size = 5775102, upload-time = "2026-07-31T03:32:24.766Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e5/1b1bae527e4de93b9eddbf15a78f1ce5198aece5c169478e385364e0f1f1/pyqwest-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:513fe7649d727cccb5d6e04999e91fed5f61f5e20a8556a2f96d3d1aeea02001", size = 5963251, upload-time = "2026-07-31T03:32:26.792Z" }, + { url = "https://files.pythonhosted.org/packages/44/14/a9d883fc59df1f34fe42764277ca26de11d74dd3aa3d9f9f4a35d7414f37/pyqwest-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:7896646bd426db0cbd7a5d4ebd0dacd59f1a2c3cdc12fb2667234e9504e33b03", size = 4834356, upload-time = "2026-07-31T03:32:28.921Z" }, + { url = "https://files.pythonhosted.org/packages/2e/49/c0640e19718c4533590bcaba647ad3145f793c66c125cb62c399e580b1a6/pyqwest-0.8.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:c4970ad95d4d51aa07980687d07e1f4003b68bc1fa1e46c9202f1b008c95662e", size = 5232611, upload-time = "2026-07-31T03:32:30.778Z" }, + { url = "https://files.pythonhosted.org/packages/50/85/25c440e1dc31320afa9de8a3ab43b0bc6f5ef5090e43fac350b5de4db623/pyqwest-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:63f341cf695b9c8b9b184a0f1c67f6e578b833502f1f21a9359f031b5b77a627", size = 5089482, upload-time = "2026-07-31T03:32:32.817Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7c/a5c49001dc0685043512c5497a7820fe091d765f93a730ace81708ed8f5e/pyqwest-0.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed43c61eebe85aa078ff922378aac9904c9d0be06c99da5826c865c28f8468dc", size = 5618803, upload-time = "2026-07-31T03:32:34.801Z" }, + { url = "https://files.pythonhosted.org/packages/39/af/6835aaeb451f629886bae0b924a864b73b906c84944be37fea43298c163a/pyqwest-0.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7988aeb1dce1ee0d3dbcf9d6c8d3a2a821d9c56402c95c057567363ed01116c0", size = 5556779, upload-time = "2026-07-31T03:32:37.044Z" }, + { url = "https://files.pythonhosted.org/packages/ea/66/2f130d7dc3341112f1a620984d4af17cacf2a17085a1eaa98dfaa35da028/pyqwest-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37ebb0bfeaa5a6b5c6ebd1cfdbb3e4d89ac343e46be07a993e8af6473ee82b1b", size = 5779456, upload-time = "2026-07-31T03:32:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/14a30458a69e6f1b8e007ff6bd6fdf867cc28de45d046374f45087d31fd8/pyqwest-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4471266ff506d02f21350921abd0adbaa08d6b7db6d24408c2ed9635e187e263", size = 5962595, upload-time = "2026-07-31T03:32:41.02Z" }, + { url = "https://files.pythonhosted.org/packages/05/22/db95030afe6c5adc90b2864e194d8074175675a90703282286e3370ecd53/pyqwest-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:5b605cb927b5bbd0b1247a5439d90068fee84802003bd6609c48e985aeb15570", size = 4824467, upload-time = "2026-07-31T03:32:42.733Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/96900a0797bf6fcf281de0f8e9cd612e13df983cc5f9ce7aa2bd21d1ecab/pyqwest-0.8.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:aa840884e19a5777159d99c9bcc38b2f0f536484dfa0edbc4041a50134afc6e5", size = 5230908, upload-time = "2026-07-31T03:32:44.706Z" }, + { url = "https://files.pythonhosted.org/packages/ae/be/55f23ecbf28c247b856caec896c5ed301ccb9e4f96e3aaa193174182e032/pyqwest-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1aabb09561a75e16d38c83d341fa3ec1c5009a7f91f300f620493e885ac1fb25", size = 5088999, upload-time = "2026-07-31T03:32:46.632Z" }, + { url = "https://files.pythonhosted.org/packages/0d/32/0c05267a312102f8840e1e08350382f6f46be7304d84e6cb9b19317e107f/pyqwest-0.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6db97734f909d865115e45c213a983a3f0a0b67697fe2937779df92a07827aaa", size = 5618462, upload-time = "2026-07-31T03:32:48.71Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e9/c36af7b2b34364fe6abc0737f8ae47bed87377c87e2a035e0429a716477a/pyqwest-0.8.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44c266a84125017b09c89199d6355c6eeffaf381bf46670c49ec46353d9e5b0", size = 5555364, upload-time = "2026-07-31T03:32:50.588Z" }, + { url = "https://files.pythonhosted.org/packages/44/18/da3f1469ba6866a695c510a287debf30034d84cc93f8cab021bf930834fb/pyqwest-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:29cd8eca7aafa14b6ef164c4360bad0db155c68ac16bc907bbadc9a414ea4b5b", size = 5777961, upload-time = "2026-07-31T03:32:52.58Z" }, + { url = "https://files.pythonhosted.org/packages/bd/89/56c3543dd06d7ef19d54c4d698455ed9f32cc779b42b896d46149a6f73f1/pyqwest-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c2d8f4057b8bd3c540c4d0b596b8110e1d49d7a06be46de6dd0db864e2238b4b", size = 5962311, upload-time = "2026-07-31T03:32:54.747Z" }, + { url = "https://files.pythonhosted.org/packages/fc/95/82a428e536b1bc1fe655f22ade122f55c476c3d06ee6c8223fe32de124ba/pyqwest-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:568be49f10da2bdc97482093f713f36b6f3b7b4392977044c942b18b32eebceb", size = 4824529, upload-time = "2026-07-31T03:32:56.775Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e3/64b5bc3152c8201864b09d398843ed4a4fdf5e66eb951ca0d4a4eddc6f13/pyqwest-0.8.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8b33afc7289534488968b3ee95fc452724cb20c0b9dc64bd432f9eadd7f8e86b", size = 5232837, upload-time = "2026-07-31T03:32:58.857Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/071af07d1d74910a9efb5c6a9e93823534aac8f56f446b8984a42660ee58/pyqwest-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:51195116f455d027348a53a04af0463b6657ebc514a311c5952df0134de14ea8", size = 5087870, upload-time = "2026-07-31T03:33:00.971Z" }, + { url = "https://files.pythonhosted.org/packages/70/d9/75f20faab72384780946107de99f1f5a98b5486462861defe4050bad7b03/pyqwest-0.8.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fd49b8955cf167efa828052c0a08a00fa29b45f712d60f75979187cf0a7342d", size = 5620684, upload-time = "2026-07-31T03:33:03.082Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5c/150e91e8d58ae22522b77de8a3a28295b484e7676ce689c33f2182db10f0/pyqwest-0.8.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dded7f50f96e53a978784db7ceab4a7e7ff7529fc94b804603e0a3cfd5915162", size = 5557460, upload-time = "2026-07-31T03:33:05.161Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/3b60ffa85a2d4ca1218d40fc00d60ff32dc0777a01e1299376ebdc76f48c/pyqwest-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c27059c54a5ead5922f4599e31b9c727cf0bf4343be813fc64695a4b7bf24dec", size = 5780272, upload-time = "2026-07-31T03:33:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/75/1c/ae5377a9cf7e0da2389b76d092a67cfd0e0ee58d6dd0ecc9eee2d1066a5c/pyqwest-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:44f0a713a3414f50123aab649bd2591dac0264b150778f10934a28c5e11d27f2", size = 5964492, upload-time = "2026-07-31T03:33:09.7Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f0/43f650ea9c68c6a4251c6e0d5ae02f7aa4e7bd35d7ea6c5ebbb15f9af2d4/pyqwest-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:6b076960044609de8611961609b3ed00ed8dedf1504d957d7c3f354e64ed6ce9", size = 4822044, upload-time = "2026-07-31T03:33:11.863Z" }, + { url = "https://files.pythonhosted.org/packages/01/c6/208138925d0e25f1c4cf4d8b5949ed590ecc71098cb1968152627a0f91fe/pyqwest-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:778ce6f447d481913c787a2ee8d8623ac0b1fe673281c686f6b99e8689815857", size = 5217325, upload-time = "2026-07-31T03:33:13.932Z" }, + { url = "https://files.pythonhosted.org/packages/8d/21/8b8dd86ee0b96672c2338816957c285b71db08a90848db68c8b937c9c0db/pyqwest-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:19d58b0f0ca2d21832c26aa096954283b57404515bf196df5b2ec894111ccc1a", size = 5076014, upload-time = "2026-07-31T03:33:15.805Z" }, + { url = "https://files.pythonhosted.org/packages/44/7f/738077c5fd9229d61965faa946c2f54ddf90e7f910162fca2f07763107c8/pyqwest-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47de00287d20233782e391083fa197e89bb531b194131520dd9ec2641b57777", size = 5602382, upload-time = "2026-07-31T03:33:17.924Z" }, + { url = "https://files.pythonhosted.org/packages/88/df/b95b88e31593689d7b3d4a6e2f5550cd686a8648f50b70ec27a2bdbe41a3/pyqwest-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9445cccde16f4a1ef0409a42677ccbd8350cdde0a407bb3db6353c0f921fa812", size = 5547598, upload-time = "2026-07-31T03:33:19.888Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/26105b8c6209dc13b0339ed2e5d3425476643619595f4d9e74acec9279ed/pyqwest-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:18f9fa7543070d31a334970b979bdc58acfde4b163fae8f012dac9cc7122632f", size = 5765677, upload-time = "2026-07-31T03:33:21.931Z" }, + { url = "https://files.pythonhosted.org/packages/54/07/d14c467eddd3d89b561b6891df15af2ace5f20f92e1fc7dfc4d33068f627/pyqwest-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ace2147bd302c13997a0baec3eca5a2176aeb78ba1a019564bd78baa72554cf3", size = 5951710, upload-time = "2026-07-31T03:33:24.263Z" }, + { url = "https://files.pythonhosted.org/packages/da/91/fa6805b5d0a85ae8f1633b90c0a1c4c81e8ccc803e4c6ace68f04012a29b/pyqwest-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ed4b17453cbfe5bfaf9afb646fa4900621f958d9fb488b356b5a819d61b5cfe1", size = 4816517, upload-time = "2026-07-31T03:33:26.371Z" }, + { url = "https://files.pythonhosted.org/packages/55/d5/b047c6fcce7536018169d33e15ad927af9d8581f3f01725303cabaf099ac/pyqwest-0.8.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b93263e0fdc8c8e33bfcb55f1a678acf8cc2d6257d6ef4c4785f0fe32f62708d", size = 5217204, upload-time = "2026-07-31T03:33:28.445Z" }, + { url = "https://files.pythonhosted.org/packages/96/ee/ccdb687682c7b969fb2ce4a87e35e8bb99a75d82c6276c8c62f87de39cc0/pyqwest-0.8.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9f7fb0e669bd0e5b024d27ed98799c2dc19fd0eb4cdde8b508e887bc0708fa51", size = 5096201, upload-time = "2026-07-31T03:33:30.364Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/7ef06ad3f5d2f23891e7ceea25132ff9d297c1a079fe420b05db13cd49c8/pyqwest-0.8.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b03c1159c0ae2b45870d3009c79942f70a01e7122810d0eb7a4273f90cd36410", size = 5620096, upload-time = "2026-07-31T03:33:32.3Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1a/ff28a9af929aec5726448b8ddceffa95c0f4be5fd18d16fe44d8f15e0847/pyqwest-0.8.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14423de4e06014bb8549405add86dca1e866b5cd9c335790fcff9eee9263ece3", size = 5561862, upload-time = "2026-07-31T03:33:34.523Z" }, + { url = "https://files.pythonhosted.org/packages/a5/7c/303f32fabcf261afb3a118a95ecb8e68e5be060e9216b1d3808bf9fd1512/pyqwest-0.8.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:91793f0b180f38d0d5eec063b4f791104ee51cd6515791325e644b3b1a02f418", size = 5782390, upload-time = "2026-07-31T03:33:36.487Z" }, + { url = "https://files.pythonhosted.org/packages/c7/40/556b7a947bb93ff33787c3d240c7b9d5f107f066663a681329cad4ce2b01/pyqwest-0.8.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:1389bbc67290e848413b9900ab358efae3991a35dc6be5169f65e507e8d4d77c", size = 5970151, upload-time = "2026-07-31T03:33:38.779Z" }, + { url = "https://files.pythonhosted.org/packages/c1/72/a12b6109c3c274a5a680f96f4b6b108dba94958075116bca4d3d2ace2eab/pyqwest-0.8.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bf5caf0e25a26145e08643be9b65831c0a2c9bf6a19d1e6c9b1ad5a2c06cf3e9", size = 4825893, upload-time = "2026-07-31T03:33:40.887Z" }, ] [[package]] From 4bd65d8bdc4c22e6586b9b8de964d3350359c5c0 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:28:39 +0200 Subject: [PATCH 09/10] refactor(python-sdk): own the ProxyTypes alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ProxyTypes` — the public type of the `proxy` option — came from httpx's private `_types` module. It is defined there as `Union["URL", str, "Proxy"]`, exactly the three forms `proxy_to_config` narrows for the pyqwest transports, so spell it out in `e2b.connection_config` and import it from there. Same public name, same type to a type checker, no private-module dependency, and a place for a pyqwest proxy type to land as the transports move off httpx. Scoped to the control-plane API client this PR moves; the volume and envd modules keep importing httpx's alias until their own PRs migrate them. Keeping `isinstance` narrowing rather than duck-typing `.url`/`.auth` is deliberate: httpx is a required dependency here (the generated REST client is an httpx client), so probing attributes would trade a clear `InvalidArgumentException` on a mistyped argument for no dependency savings. Co-Authored-By: Claude Opus 5 (1M context) --- packages/python-sdk/e2b/api/__init__.py | 3 +-- .../python-sdk/e2b/api/client_async/__init__.py | 3 +-- .../python-sdk/e2b/api/client_sync/__init__.py | 3 +-- packages/python-sdk/e2b/connection_config.py | 14 ++++++++++++-- .../python-sdk/tests/test_api_client_transport.py | 3 +-- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/python-sdk/e2b/api/__init__.py b/packages/python-sdk/e2b/api/__init__.py index d9dcce0a4b..a6e10900bc 100644 --- a/packages/python-sdk/e2b/api/__init__.py +++ b/packages/python-sdk/e2b/api/__init__.py @@ -8,13 +8,12 @@ import httpx from httpx import AsyncBaseTransport, BaseTransport, Limits, Timeout -from httpx._types import ProxyTypes from pyqwest import Proxy from e2b.api.client.client import AuthenticatedClient from e2b.api.client.types import Response from e2b.api.metadata import default_headers -from e2b.connection_config import ConnectionConfig +from e2b.connection_config import ConnectionConfig, ProxyTypes from e2b.exceptions import ( AuthenticationException, InvalidArgumentException, diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index 2d8d58cfd2..d4de1b49a8 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -5,7 +5,6 @@ import httpx -from httpx._types import ProxyTypes from pyqwest import HTTPTransport, Request, Response from pyqwest.httpx import AsyncPyqwestTransport from pyqwest.middleware.retry import RetryTransport @@ -19,7 +18,7 @@ pool_max_idle_per_host, proxy_to_config, ) -from e2b.connection_config import ConnectionConfig +from e2b.connection_config import ConnectionConfig, ProxyTypes TransportKey = Tuple[bool, Optional[ProxyTypes]] diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index a087aa6138..b5ffa06ee3 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -3,7 +3,6 @@ import httpx import threading -from httpx._types import ProxyTypes from pyqwest import SyncHTTPTransport, SyncRequest, SyncResponse from pyqwest.httpx import PyqwestTransport from pyqwest.middleware.retry import SyncRetryTransport @@ -17,7 +16,7 @@ pool_max_idle_per_host, proxy_to_config, ) -from e2b.connection_config import ConnectionConfig +from e2b.connection_config import ConnectionConfig, ProxyTypes TransportKey = Tuple[bool, Optional[ProxyTypes]] diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 81f4a09460..828c3571f9 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -1,14 +1,24 @@ import logging import os -from typing import cast, Optional, Dict, TypedDict +from typing import cast, Optional, Dict, TypedDict, Union -from httpx._types import ProxyTypes +import httpx from typing_extensions import Unpack from e2b.api.metadata import package_version from e2b.sandbox_domains import is_supported_sandbox_domain +ProxyTypes = Union[str, httpx.URL, httpx.Proxy] +"""The forms the ``proxy`` option accepts: a URL string, an ``httpx.URL``, or +an ``httpx.Proxy``. + +Identical to ``httpx._types.ProxyTypes``, spelled out here so the SDK doesn't +import httpx's private module at runtime — and so the union can grow a pyqwest +proxy type as the transports move off httpx. :func:`e2b.api.proxy_to_config` +narrows it to what the pyqwest REST transports take. +""" + REQUEST_TIMEOUT: float = 60.0 # 60 seconds KEEPALIVE_PING_INTERVAL_SEC = 50 # 50 seconds diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index 3c08885e18..97fc9c32fd 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -11,7 +11,6 @@ import httpx import pytest -from httpx._types import ProxyTypes from pyqwest import ( Headers, Proxy, @@ -35,7 +34,7 @@ from e2b.api.client_sync import get_api_client as get_sync_api_client from e2b.api.client_sync import get_envd_transport as get_sync_envd_transport from e2b.api.client_sync import get_transport as get_sync_transport -from e2b.connection_config import ConnectionConfig +from e2b.connection_config import ConnectionConfig, ProxyTypes from e2b.exceptions import InvalidArgumentException From 7ce18fdfcbc0cb386e59520fcc882f71e408480a Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:36:37 +0200 Subject: [PATCH 10/10] fix(python-sdk): map response-body timeouts to httpx.ReadTimeout The pyqwest httpx adapter returns as soon as the response head arrives and httpx consumes the body afterwards, so the transport's timeout remap only covered the head: a body that stalled mid-transfer surfaced pyqwest's builtin TimeoutError instead of the httpx.ReadTimeout the generated client documents. Wrap the returned stream so the mapping follows body reads too. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/python-pyqwest-rest-transport.md | 3 +- .../e2b/api/client_async/__init__.py | 30 ++++++++++++- .../e2b/api/client_sync/__init__.py | 29 ++++++++++++- .../tests/test_api_client_transport.py | 42 ++++++++++++++++++- 4 files changed, 98 insertions(+), 6 deletions(-) diff --git a/.changeset/python-pyqwest-rest-transport.md b/.changeset/python-pyqwest-rest-transport.md index 1909dae142..ece3a4e0c3 100644 --- a/.changeset/python-pyqwest-rest-transport.md +++ b/.changeset/python-pyqwest-rest-transport.md @@ -17,7 +17,8 @@ single httpx client serves all threads and event loops. Connection-establishment failures are retried with backoff (`E2B_CONNECTION_RETRIES`, default 3), matching the connect-only retries of the previous transports. Timeouts keep raising `httpx.ReadTimeout` (an -`httpx.TimeoutException`), as before. +`httpx.TimeoutException`), as before, whether they fire while waiting for the +response head or while reading the response body. `proxy` for API calls takes a URL string (e.g. `proxy="http://user:pass@localhost:8030"`, scheme http, https, socks5, or diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index d4de1b49a8..86d0d592aa 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -1,7 +1,7 @@ import asyncio import threading import weakref -from typing import Dict, Optional, Tuple, Union +from typing import AsyncIterator, Dict, Optional, Tuple, Union, cast import httpx @@ -47,9 +47,35 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response: if "host" in request.headers: del request.headers["host"] try: - return await super().handle_async_request(request) + response = await super().handle_async_request(request) except (TimeoutError, asyncio.TimeoutError) as e: raise httpx.ReadTimeout(str(e), request=request) from e + # The adapter returns once the response head arrives and httpx reads + # the body afterwards, so a body that stalls times out beyond the + # try above — the mapping has to follow the stream. + response.stream = AsyncReadTimeoutByteStream( + cast(httpx.AsyncByteStream, response.stream), request + ) + return response + + +class AsyncReadTimeoutByteStream(httpx.AsyncByteStream): + """Maps a timeout while reading the response body to ``httpx.ReadTimeout``, + the way ``AsyncApiPyqwestTransport`` maps one while awaiting the head.""" + + def __init__(self, stream: httpx.AsyncByteStream, request: httpx.Request) -> None: + self._stream = stream + self._request = request + + async def __aiter__(self) -> AsyncIterator[bytes]: + try: + async for chunk in self._stream: + yield chunk + except (TimeoutError, asyncio.TimeoutError) as e: + raise httpx.ReadTimeout(str(e), request=self._request) from e + + async def aclose(self) -> None: + await self._stream.aclose() class ConnectionRetryTransport(RetryTransport): diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index b5ffa06ee3..504e406ede 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional, Tuple, Union +from typing import Dict, Iterator, Optional, Tuple, Union, cast import httpx import threading @@ -43,9 +43,34 @@ def handle_request(self, request: httpx.Request) -> httpx.Response: if "host" in request.headers: del request.headers["host"] try: - return super().handle_request(request) + response = super().handle_request(request) except TimeoutError as e: raise httpx.ReadTimeout(str(e), request=request) from e + # The adapter returns once the response head arrives and httpx reads + # the body afterwards, so a body that stalls times out beyond the + # try above — the mapping has to follow the stream. + response.stream = ReadTimeoutByteStream( + cast(httpx.SyncByteStream, response.stream), request + ) + return response + + +class ReadTimeoutByteStream(httpx.SyncByteStream): + """Maps a timeout while reading the response body to ``httpx.ReadTimeout``, + the way ``ApiPyqwestTransport`` maps one while awaiting the head.""" + + def __init__(self, stream: httpx.SyncByteStream, request: httpx.Request) -> None: + self._stream = stream + self._request = request + + def __iter__(self) -> Iterator[bytes]: + try: + yield from self._stream + except TimeoutError as e: + raise httpx.ReadTimeout(str(e), request=self._request) from e + + def close(self) -> None: + self._stream.close() class ConnectionRetryTransport(SyncRetryTransport): diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index 97fc9c32fd..b636a80c8d 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -390,7 +390,8 @@ async def test_async_envd_transport_uses_separate_stack(test_api_key): class _EchoHandler(BaseHTTPRequestHandler): """Answers every GET with a JSON echo of the request headers; a path - starting with ``/slow`` sleeps 5 seconds first.""" + starting with ``/slow`` sleeps 5 seconds first, and one starting with + ``/stall`` answers the head and then never sends the body.""" def do_GET(self): if self.path.startswith("/slow"): @@ -401,6 +402,10 @@ def do_GET(self): self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() + if self.path.startswith("/stall"): + self.wfile.flush() + time.sleep(5) + return self.wfile.write(body) def log_message(self, *args): @@ -580,3 +585,38 @@ async def test_async_api_client_timeout_raises_httpx_read_timeout( finally: await httpx_client.aclose() reset_async_api_transports() + + +def test_sync_api_client_body_timeout_raises_httpx_read_timeout( + test_api_key, echo_server +): + # The head arrives in time and the body never does: httpx reads the body + # after the transport returned, so that timeout is mapped on the stream. + reset_sync_api_transports() + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) + api_client = get_sync_api_client(config) + httpx_client = api_client.get_httpx_client() + + try: + with pytest.raises(httpx.ReadTimeout): + httpx_client.request("GET", "/stall", timeout=0.2) + finally: + httpx_client.close() + reset_sync_api_transports() + + +@pytest.mark.asyncio +async def test_async_api_client_body_timeout_raises_httpx_read_timeout( + test_api_key, echo_server +): + reset_async_api_transports() + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) + api_client = get_async_api_client(config) + httpx_client = api_client.get_async_httpx_client() + + try: + with pytest.raises(httpx.ReadTimeout): + await httpx_client.request("GET", "/stall", timeout=0.2) + finally: + await httpx_client.aclose() + reset_async_api_transports()