Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/python-pyqwest-rest-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
"@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), 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. Timeouts keep raising `httpx.ReadTimeout` (an
`httpx.TimeoutException`), as before.

`proxy` for API calls takes a URL string (e.g.
`proxy="http://user:pass@localhost:8030"`, scheme http, https, socks5, or
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,
health checks) keeps its httpx transports.
140 changes: 67 additions & 73 deletions packages/python-sdk/e2b/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
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 NamedTuple, Optional, Protocol, Tuple, Union

import httpx
from httpx import AsyncBaseTransport, BaseTransport, Limits, Timeout
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,
RateLimitException,
SandboxException,
)
Expand Down Expand Up @@ -70,6 +69,61 @@ 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")


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 ProxyConfig(proxy)
if isinstance(proxy, httpx.URL):
return ProxyConfig(str(proxy))
if isinstance(proxy, httpx.Proxy):
if proxy.ssl_context is not None:
raise InvalidArgumentException(
"E2B API calls don't support httpx.Proxy ssl_context"
)
# 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, httpx.URL, and httpx.Proxy "
'proxies, e.g. proxy="http://user:pass@localhost:8030"'
)


@dataclass
class SandboxCreateResponse:
Expand Down Expand Up @@ -154,31 +208,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:
Expand Down Expand Up @@ -223,12 +266,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
Expand All @@ -249,53 +290,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):
Expand Down
Loading
Loading