feat(python-sdk): move the REST API client onto pyqwest's httpx transport adapter (#1601)

## What

Migrate all httpx REST API client traffic in the Python SDK — the E2B
control plane (sandbox lifecycle, listing, templates, volumes control
plane) — to [pyqwest](https://github.com/curioswitch/pyqwest) (Rust
reqwest/hyper), using its httpx-compatible transport adapter
(`pyqwest.httpx.PyqwestTransport` / `AsyncPyqwestTransport`). The
generated openapi client and `ApiClient`/`AsyncApiClient` keep their
httpx surface — logging event hooks, per-request timeouts, headers, and
redirects behave as before — only the transport underneath is swapped.

envd RPC already runs on pyqwest via connectrpc (#1558). This PR touches
only the control-plane client; the rest of the stack builds on it: #1623
(envd HTTP API client), #1602 (volume content client), #1603 (template
uploads).

Requires **pyqwest 0.9** — pinned in `pyproject.toml` (`>=0.9.0,<0.10`)
with `uv.lock` refreshed. 0.8 brought the `Proxy` object
([pyqwest#194](https://github.com/curioswitch/pyqwest/pull/194)) and
request loggers
([pyqwest#197](https://github.com/curioswitch/pyqwest/pull/197)); 0.9
([release
notes](https://github.com/curioswitch/pyqwest/discussions/214)) folds
the two adapter workarounds this PR used to carry into the adapter
itself and makes redirect handling configurable, so the SDK no longer
subclasses the adapter at all.

## How

- `e2b/api/client_sync/__init__.py` / `client_async/__init__.py`:
`get_transport` now returns a pyqwest-backed httpx transport — a
`SyncHTTPTransport`/`HTTPTransport` (`tls_include_system_certs=True`,
proxy, pool tuning mapped from
`E2B_KEEPALIVE_EXPIRY`/`E2B_MAX_KEEPALIVE_CONNECTIONS`), wrapped in a
`ConnectionRetryTransport` for connect-only retries honoring
`E2B_CONNECTION_RETRIES`, wrapped in the stock
`PyqwestTransport`/`AsyncPyqwestTransport` httpx adapter.
- pyqwest transports are thread-safe and loop-independent (I/O runs on a
Rust tokio runtime), so the caches are process-global keyed by proxy —
previously one pool per thread (sync) / per event loop (async).
- **`ApiClient` sheds its threading machinery**: the
`transport_factory`/`async_transport_factory` plumbing, the thread-local
`httpx.Client` cache, and the per-loop `WeakKeyDictionary` of
`AsyncClient`s are gone. A single lazily-created httpx client (the
generated base behavior, the same shape the volume client already uses)
serves all threads and event loops; `httpx.Client` is documented
thread-safe and nothing below it is loop-bound. Closing that client
can't tear down the shared pool — the adapter transports don't override
`close()`/`aclose()`.
- **Host header** (upstream in 0.9): sending the `Host` header httpx
auto-adds on an HTTP/2 connection makes the E2B API edge reset the
stream with `PROTOCOL_ERROR` (reproduced with plain pyqwest against
`api.e2b.app`); hyper derives `Host`/`:authority` from the URL. The
adapter now skips a `host` header matching the URL, so the SDK-side
strip is gone — and unlike that strip, a genuinely custom `Host`
override is still forwarded.
- **Timeout exceptions** (upstream in 0.9): pyqwest raises the builtin
`TimeoutError`; the adapter maps it to `httpx.ReadTimeout` both while
awaiting the response head and while reading the body, preserving the
`httpx.TimeoutException` contract for callers. Connection, network, and
protocol failures likewise arrive as
`httpx.ConnectError`/`ConnectTimeout`, `httpx.ReadError`/`WriteError`,
and `httpx.RemoteProtocolError` instead of leaking pyqwest/builtin
types.
- **Redirects**: the pyqwest transports are built with
`follow_redirects=False` (0.9 made it configurable; reqwest's default is
to follow). Otherwise redirects are followed inside the transport,
hiding 3xx responses from httpx and leaving `response.history` empty —
even though the generated clients ask for no redirect following. httpx
owns them again, as with the transports this replaced.
- **Proxy**: `proxy=` accepts a URL string, `httpx.URL`, or an
`httpx.Proxy` — including its credentials (sent as
`Proxy-Authorization`) and any headers configured for the proxy, via
pyqwest's `Proxy` object. `proxy_to_config` normalizes all three into a
`ProxyConfig` tuple that both keys the transport cache and builds the
`pyqwest.Proxy`, so the same proxy URL with different credentials or
headers gets its own pool. A per-proxy `ssl_context` has no counterpart
and raises `InvalidArgumentException` rather than being silently
dropped. (`ProxyConfig` is a `NamedTuple`, not a frozen dataclass:
`tests/test_env_var_parsing.py` reloads `e2b.api`, and a dataclass
`__eq__` compares class identity, so keys built before and after a
reload would silently stop matching.)
- **`ProxyTypes` is ours now**: the public type of the `proxy` option
(already exported from `e2b`) used to be imported at runtime from
httpx's private `_types` module in eleven modules. It is defined there
as `Union[str, URL, Proxy]` — exactly the three forms the SDK's two
narrowers accept — so it's spelled out once in `e2b.connection_config`
and imported 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 remaining transports move off httpx.
`e2b.envd.client_shared.proxy_to_url` took a bare `object` while
`e2b.api.proxy_to_config` took `Optional[ProxyTypes]`; both now say the
same thing. `isinstance` narrowing stays rather than duck-typing
`.url`/`.auth` — httpx is a required dependency here (the generated REST
client *is* an httpx client, and envd file transfers use httpx
directly), so probing attributes would trade a clear
`InvalidArgumentException` on a mistyped argument for no dependency
savings.
- **Request logs**: pyqwest logs one line per request on the
`pyqwest.access` logger and lifecycle records on `pyqwest`, both at
`DEBUG` — the transport-level diagnostics httpcore used to provide, now
that httpcore is out of the path. Noted on `get_transport`; the SDK's
own `logger` option is unchanged and sits above it on the httpx client.
- **HTTP/2**: negotiated via ALPN for TLS connections (reqwest default),
equivalent to the `http2=True` transports this replaces.

## What stays behind (handled by the stacked PRs)

- **envd HTTP API client** (file transfers, health checks): #1623, which
also dedupes the transport plumbing this PR and #1558 each carry a copy
of (the proxy narrowing, pool tuning, retry transport — envd keeps
byte-identical duplicates until then).
- **Volume content client**: its streaming download relies on httpx's
per-read `read` timeout as an *idle* timeout, which the adapter can't
express per request — #1602.
- **Template build context upload**: one-off httpx client PUTing to S3
presigned URLs — #1603.

## Timeout semantics note

`request_timeout` was previously httpx's per-phase timeout
(connect/read/write each bounded separately, so a slow multi-phase
request could exceed it in total). Through the adapter it becomes an
overall deadline per API call (async: headers + body; sync: up to
response headers). For the SDK's REST calls — all unary with small JSON
bodies — this is a tightening, arguably closer to what `request_timeout`
promises.

## Testing

- `tests/test_api_client_transport.py` rewritten for the new semantics:
global per-proxy transport caching, a single httpx client shared across
threads/loops (including 32-way concurrent request tests against a local
server), timeout → `httpx.ReadTimeout` mapping for both the response
head and a stalled body (slow/stalling local server), redirects
surfacing to httpx (302 returned as-is, `response.history` populated
when the caller opts in), the connection-only retry policy,
`proxy_to_config` conversion, and sync+async round-trips through a real
local HTTP server exercising pyqwest end to end. The two host-header
unit tests are gone with the subclasses they tested — that behavior is
the adapter's now.
- Two tests cover the pyqwest proxy/logging surface: an echo server
standing in for a proxy asserts that the absolute-form request target,
`Proxy-Authorization`, and the extra proxy header actually arrive, and
the `pyqwest.access` record is asserted for an API call.
- On pyqwest 0.9.0 from PyPI: `uv sync --locked`, unit suite
(`tests/*.py`, 238 passed), `ruff check`, `ty check` — all green.
- Integration against the production API (real key) was run on 0.8.0:
`tests/sync/api_sync`, `tests/async/api_async`,
create/kill/timeout/connect — all green. (These initially failed with
`RemoteProtocolError: StreamReset` until the host header stopped being
forwarded, so they genuinely exercise the new stack; that fix now comes
from the adapter.)

## Usage example

No API changes for the common path:

```python
from e2b import Sandbox

sbx = Sandbox.create()          # control-plane calls now go through pyqwest
Sandbox.list()
sbx.kill()
```

Proxy handling — URL strings and `httpx.Proxy` objects work, credentials
and proxy headers included:

```python
Sandbox.create(proxy="http://user:pass@localhost:8030")            # ok (unchanged)
Sandbox.create(proxy=httpx.Proxy("http://localhost:8030",
                                 auth=("user", "pass")))           # sent as Proxy-Authorization
Sandbox.create(proxy=httpx.Proxy("http://localhost:8030",
                                 headers={"X-Auth": "t"}))         # sent to the proxy
Sandbox.create(proxy=httpx.Proxy("https://localhost:8030",
                                 ssl_context=ctx))                 # raises InvalidArgumentException
```

`ProxyTypes` — already exported from `e2b` — is now defined by the SDK
rather than re-exported from `httpx._types`, with the same three
members:

```python
from e2b import ProxyTypes   # Union[str, httpx.URL, httpx.Proxy]
```

Transport-level HTTP logs, replacing the httpcore records this migration
removes:

```python
import logging

logging.basicConfig()
logging.getLogger("pyqwest.access").setLevel(logging.DEBUG)

Sandbox.create()
# DEBUG pyqwest.access - HTTP Request: POST https://api.e2b.app/sandboxes "HTTP/2 201 Created"
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mish Ushakov
2026-08-10 19:46:37 +02:00
committed by GitHub
parent cab27aa6fa
commit a874ced97a
8 changed files with 766 additions and 408 deletions
@@ -0,0 +1,56 @@
---
"@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, headers, and redirect handling (`follow_redirects`,
`response.history`) behave as before.
One timeout semantics change: through the adapter, `request_timeout` is a
deadline for the whole API call, where the previous transports applied it to
each phase (connect, read, write) separately — a slow request could exceed it
in total. For the REST API's small JSON exchanges this tightening is what
`request_timeout` reads as promising; `0` still disables it.
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, whether they fire while waiting for the
response head or while reading the response body, and connection, network, and
protocol failures keep raising their `httpx` counterparts (`httpx.ConnectError`,
`httpx.ReadError`, `httpx.RemoteProtocolError`).
`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.
+67 -73
View File
@@ -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,
)
@@ -70,6 +69,61 @@ limits = Limits(
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:
@@ -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:
@@ -223,12 +266,10 @@ class ApiClient(AuthenticatedClient):
"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
@@ -249,53 +290,6 @@ class ApiClient(AuthenticatedClient):
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):
@@ -1,32 +1,92 @@
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 pyqwest import HTTPTransport, Request, Response
from pyqwest.httpx import AsyncPyqwestTransport
from pyqwest.middleware.retry import RetryTransport
from e2b.api import AsyncApiClient, connection_retries, limits
from e2b.connection_config import ConnectionConfig
from e2b.api import (
AsyncApiClient,
ProxyConfig,
connection_retries,
limits,
pool_idle_timeout,
pool_max_idle_per_host,
proxy_to_config,
)
from e2b.connection_config import ConnectionConfig, ProxyTypes
TransportKey = Tuple[bool, Optional[ProxyTypes]]
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 AsyncTransportWithLogger(httpx.AsyncHTTPTransport):
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[ProxyConfig], AsyncPyqwestTransport] = {}
def get_transport(config: ConnectionConfig) -> AsyncPyqwestTransport:
"""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.
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)
if transport is None:
transport = AsyncPyqwestTransport(
ConnectionRetryTransport(
HTTPTransport(
tls_include_system_certs=True,
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,
# Redirects belong to the httpx client above (which the
# generated clients leave off), not to reqwest.
follow_redirects=False,
),
max_retries=connection_retries,
)
)
_transports[proxy] = 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 +94,19 @@ class AsyncTransportWithLogger(httpx.AsyncHTTPTransport):
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 +115,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)
@@ -1,25 +1,83 @@
from typing import Dict, Optional, Tuple
from typing import Dict, Optional, Tuple, Union
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
from e2b.api import ApiClient, connection_retries, limits
from e2b.connection_config import ConnectionConfig
from e2b.api import (
ApiClient,
ProxyConfig,
connection_retries,
limits,
pool_idle_timeout,
pool_max_idle_per_host,
proxy_to_config,
)
from e2b.connection_config import ConnectionConfig, ProxyTypes
TransportKey = Tuple[bool, Optional[ProxyTypes]]
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 TransportWithLogger(httpx.HTTPTransport):
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[ProxyConfig], PyqwestTransport] = {}
def get_transport(config: ConnectionConfig) -> PyqwestTransport:
"""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.
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)
if transport is None:
transport = PyqwestTransport(
ConnectionRetryTransport(
SyncHTTPTransport(
tls_include_system_certs=True,
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,
# Redirects belong to the httpx client above (which the
# generated clients leave off), not to reqwest.
follow_redirects=False,
),
max_retries=connection_retries,
)
)
_transports[proxy] = transport
return transport
class EnvdTransportWithLogger(httpx.HTTPTransport):
_thread_local = threading.local()
@property
@@ -27,30 +85,6 @@ class TransportWithLogger(httpx.HTTPTransport):
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:
+12 -2
View File
@@ -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
+1 -1
View File
@@ -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.9.0,<0.10",
]
[project.urls]
@@ -1,23 +1,46 @@
import asyncio
import gc
import base64
import json
import logging
import ssl
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import cast
import httpx
import pytest
from pyqwest import (
Proxy,
Request,
SyncRequest,
SyncTransport,
Transport,
)
from pyqwest.httpx import AsyncPyqwestTransport, PyqwestTransport
from e2b.api.client_async import AsyncEnvdTransportWithLogger, AsyncTransportWithLogger
import e2b.api.client_async as client_async
import e2b.api.client_sync as client_sync
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
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.connection_config import ConnectionConfig, ProxyTypes
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 +52,76 @@ def run_in_worker_thread(fn):
return executor.submit(fn).result()
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_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()))
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()))
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_client_proxy_uses_explicit_transport(test_api_key):
reset_sync_api_transports()
config = ConnectionConfig(
@@ -42,31 +135,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 +197,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,67 +208,31 @@ 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,
):
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 not main_transport
finally:
reset_sync_api_transports()
def test_sync_api_client_cache_reuses_within_thread_and_isolates_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),
id(get_sync_transport(config)),
)
finally:
httpx_client.close()
try:
main_client = api_client.get_httpx_client()
(
worker_client_id,
worker_cached_client_id,
worker_transport_id,
worker_cached_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
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_client is main_client
finally:
main_client.close()
reset_sync_api_transports()
@@ -207,7 +248,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 +257,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 +265,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,135 +294,39 @@ 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()
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()
AsyncTransportWithLogger._instances.clear()
@pytest.mark.asyncio
async def test_async_api_client_cache_reuses_within_loop_and_isolates_across_loops(
test_api_key,
):
AsyncTransportWithLogger._instances.clear()
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),
id(get_async_transport(config)),
)
finally:
await httpx_client.aclose()
try:
main_client = api_client.get_async_httpx_client()
(
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
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)
finally:
await main_client.aclose()
AsyncTransportWithLogger._instances.clear()
def test_async_transport_not_reused_across_sequential_loops(test_api_key):
AsyncTransportWithLogger._instances.clear()
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 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
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
finally:
AsyncTransportWithLogger._instances.clear()
def test_async_api_client_not_reused_across_sequential_loops(test_api_key):
AsyncTransportWithLogger._instances.clear()
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():
client = api_client.get_async_httpx_client()
assert api_client.get_async_httpx_client() is client
return client
return api_client.get_async_httpx_client()
try:
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()
main_client = api_client.get_async_httpx_client()
other_loop_client = await asyncio.get_running_loop().run_in_executor(
None,
lambda: asyncio.run(get_client()),
)
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 api_client.get_async_httpx_client() is main_client
assert other_loop_client is main_client
finally:
AsyncTransportWithLogger._instances.clear()
await main_client.aclose()
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 +334,309 @@ 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; a path
starting with ``/slow`` sleeps 5 seconds first, one starting with
``/stall`` answers the head and then never sends the body, and one starting
with ``/redirect`` answers 302 pointing at ``/sandboxes``."""
def do_GET(self):
if self.path.startswith("/slow"):
time.sleep(5)
if self.path.startswith("/redirect"):
self.send_response(302)
self.send_header("Location", "/sandboxes")
self.send_header("Content-Length", "0")
self.end_headers()
return
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()
if self.path.startswith("/stall"):
self.wfile.flush()
time.sleep(5)
return
self.wfile.write(body)
def log_message(self, *args):
pass
class _EchoServer(ThreadingHTTPServer):
# The concurrency tests open 32 connections at once. Windows resets
# connections that overflow the listen backlog (request_queue_size,
# default 5) instead of queueing them, which surfaces as a flaky
# "connection was forcibly closed" WriteError mid-test.
request_queue_size = 64
@pytest.fixture
def echo_server():
server = _EchoServer(("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_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)
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()
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()
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()
def test_sync_api_client_leaves_redirects_to_httpx(test_api_key, echo_server):
# reqwest would otherwise follow redirects inside the transport, hiding them
# from httpx: the generated client asks for no redirect following, so a 302
# must surface as-is, and opting in must record the hop in `history`.
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 httpx_client.follow_redirects is False
response = httpx_client.request("GET", "/redirect")
assert response.status_code == 302
assert response.headers["location"] == "/sandboxes"
assert response.history == []
followed = httpx_client.request("GET", "/redirect", follow_redirects=True)
assert followed.status_code == 200
assert followed.json()["path"] == "/sandboxes"
assert [r.status_code for r in followed.history] == [302]
finally:
httpx_client.close()
reset_sync_api_transports()
@pytest.mark.asyncio
async def test_async_api_client_leaves_redirects_to_httpx(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 httpx_client.follow_redirects is False
response = await httpx_client.request("GET", "/redirect")
assert response.status_code == 302
assert response.headers["location"] == "/sandboxes"
assert response.history == []
followed = await httpx_client.request("GET", "/redirect", follow_redirects=True)
assert followed.status_code == 200
assert followed.json()["path"] == "/sandboxes"
assert [r.status_code for r in followed.history] == [302]
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()
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()
+45 -45
View File
@@ -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.9.0,<0.10" },
{ 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.9.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" }
sdist = { url = "https://files.pythonhosted.org/packages/8d/59/97531fd9d0a06d54e84f5493775739b0c1d0d13ec704974d04fa423a3332/pyqwest-0.9.0.tar.gz", hash = "sha256:514dd0b37d7a1bcb978b5d6423d15f89cf7dcf8058ac2a37aa42cd30090de89e", size = 477462, upload-time = "2026-08-10T01:55:36.219Z" }
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" },
{ url = "https://files.pythonhosted.org/packages/47/34/d556fa07d9bff21fc6c9c16738614c6336164b65d9ced1ce9f1c1044aaeb/pyqwest-0.9.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:517d2eb295d56ef1530cebe0cf290a66fa199c57955bd1c5f1dc592240fbb7d6", size = 5224594, upload-time = "2026-08-10T01:54:18.646Z" },
{ url = "https://files.pythonhosted.org/packages/c9/3f/1d7d1a08ebf8838c0c85e99ab5db5c1aae457f76ee03b60c8f4fc4948e01/pyqwest-0.9.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:408dc692835363b6824bee61ee9e4c9a5bba9c08d75b903f82f9a802e41d1b30", size = 5100753, upload-time = "2026-08-10T01:54:20.669Z" },
{ url = "https://files.pythonhosted.org/packages/66/fe/22c4056b526baa4c2c953f4e99925ce0bb14e7747d66e6ac3448a34f76e5/pyqwest-0.9.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1abf1ab3a0c1b545651ab37a643acc7909c4e7644a63a2e4af36e3e230a0db5", size = 5619882, upload-time = "2026-08-10T01:54:22.468Z" },
{ url = "https://files.pythonhosted.org/packages/de/2c/8f1159a30bbc905b2fcbc386740a260e76ae64372feb263103438ef5201c/pyqwest-0.9.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b02e5c9ce57e079eb3716c464d3818ad2573d7743483c39526ad77ee95af806", size = 5564744, upload-time = "2026-08-10T01:54:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/ac/fd/ff15034d7874fd208d606bd80c924fb0b1091db8159c892672168b656de1/pyqwest-0.9.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:98bdaa30a3a8d8c71506a33c084e485a243fbf51e77130bb39e0f249dd116142", size = 5779373, upload-time = "2026-08-10T01:54:26.753Z" },
{ url = "https://files.pythonhosted.org/packages/ed/b5/eeafdeb65ad8fe9c39044aedcfc97610f4e5c7b5620ca9fe2d504f669108/pyqwest-0.9.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:10eba84f9d8512d23bd16dd60b1c77dab956470361559214981dda065df4ad1a", size = 5973306, upload-time = "2026-08-10T01:54:28.729Z" },
{ url = "https://files.pythonhosted.org/packages/34/db/a8979dab5c591529f858d1505d09b3c8b6dec337921f15bed4858c55716e/pyqwest-0.9.0-cp310-abi3-win_amd64.whl", hash = "sha256:af4e859800b02cd1fcdd898af26a881c4a8ce9520f71f6cb0a5403523d2cbbdf", size = 4834706, upload-time = "2026-08-10T01:54:30.335Z" },
{ url = "https://files.pythonhosted.org/packages/1d/9e/d070ca1ce3c202952770436172889a4b0556a564a490baf726f7bb895eba/pyqwest-0.9.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:abe322cc1b63947b493bc06028615e0d5de655928159227aab64f60a0eb0e27d", size = 5244745, upload-time = "2026-08-10T01:54:32.257Z" },
{ url = "https://files.pythonhosted.org/packages/41/97/ebded0bb35a79f51bb2d1519e34922d79c8825f9e71b696bb20f387a08c2/pyqwest-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c6b2dc6a9c583859d031941e1041b8e711afe0ad4c130628207731cef2b1a486", size = 5101044, upload-time = "2026-08-10T01:54:34.088Z" },
{ url = "https://files.pythonhosted.org/packages/a9/2c/a7f117e793b4e27642807bf7230b9fa297ff1e1ac489fc260fc3d4350ae4/pyqwest-0.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:551e47714da72a72939958029eb37a754cecd974344471c4a2038583a66a5396", size = 5630841, upload-time = "2026-08-10T01:54:35.885Z" },
{ url = "https://files.pythonhosted.org/packages/b4/b4/be47fc141529941352a848cd77da581803137b733993a51bf464f2c48915/pyqwest-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3be3d38ccdab3077bf1cfe90c346ce927f9b49c56dcdf20e060f5f2502d01021", size = 5572909, upload-time = "2026-08-10T01:54:37.653Z" },
{ url = "https://files.pythonhosted.org/packages/cb/fd/703635e12710ce7ef5e961f5e1b22c609aecc8f07eedd79477b48d1edb74/pyqwest-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:45d9d424da878b22604799d1ce2a0a5df054924487ee5a851ad08025e2076e5c", size = 5794049, upload-time = "2026-08-10T01:54:39.363Z" },
{ url = "https://files.pythonhosted.org/packages/3c/15/941aad56b108c743df9d41e124be82ecb6be0eba1007e945b6a39f5dd1a0/pyqwest-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e4420adcd2b756da706b31a04f095a521f6e440dab284b7a1b46c76e048c1f78", size = 5974542, upload-time = "2026-08-10T01:54:41.202Z" },
{ url = "https://files.pythonhosted.org/packages/b7/86/8445fe05b47322047347464a909ae95dba023bf8409b9967f66bda2905ad/pyqwest-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:25734bd9798bbfaa53c83d395649dd9ea9a791a64b9848734955cb78ad52c832", size = 4847034, upload-time = "2026-08-10T01:54:42.899Z" },
{ url = "https://files.pythonhosted.org/packages/a7/92/013fc3c94f0dc4eeca556431797da477d76ad43d69134837ba149468dcc3/pyqwest-0.9.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6e4c2afe6c7293d9e42cfdb0489b0b5f64002159c2dc9c45d9322c1b4ffb42cd", size = 5244793, upload-time = "2026-08-10T01:54:44.891Z" },
{ url = "https://files.pythonhosted.org/packages/48/61/c685e0c595f3f1b7842a776c48aaf2b3f47270a61672967d9d38f7c5e7f6/pyqwest-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:67f153b94dfbb9aeabc38f20a1bada1acfd9361bec12db86268bd951be35a154", size = 5100715, upload-time = "2026-08-10T01:54:46.777Z" },
{ url = "https://files.pythonhosted.org/packages/f9/a1/a28b2cd704d7dd37043f1e6b398798576c12293f3efae84f00e7f6a5625c/pyqwest-0.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9942ffa243c8c2243e3ce04e3e3ec9b4c119346c963fd0051cd989f0defecf6", size = 5628787, upload-time = "2026-08-10T01:54:48.488Z" },
{ url = "https://files.pythonhosted.org/packages/36/31/6255c7f17cd00e39f1c4df3003cea2d2d087e483fb4c49a6535307cd21e0/pyqwest-0.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f8f9c880c19826fead1838e12b042865b15df3ec844928cc74188a8b740f761", size = 5571946, upload-time = "2026-08-10T01:54:50.181Z" },
{ url = "https://files.pythonhosted.org/packages/37/71/344738bdb38bddbc07cb29e5b1287c08db9e53d6b322581492571bdef748/pyqwest-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:56040761f6ca7abb036c7d288863bf143a9b166d824d98a604908ef20c6906e0", size = 5792872, upload-time = "2026-08-10T01:54:52.045Z" },
{ url = "https://files.pythonhosted.org/packages/fc/ee/c5c74e3afb6dd0a25bca23bb4757d1a7135fab09a0a054036e837d49dd11/pyqwest-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:02a51df744521e45eb5379bc325b91de4512ec64a9f4aba3248f9166ec4e5b88", size = 5974192, upload-time = "2026-08-10T01:54:53.818Z" },
{ url = "https://files.pythonhosted.org/packages/6e/08/302960836e3e43ca3051313ee3b767534d1610bd3b8033c7bbad41b06e1b/pyqwest-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9a0e3123aee446d5f6e57c43cae87b4dab4b1af3fa970d114d74d5d4b9d075c", size = 4847050, upload-time = "2026-08-10T01:54:56.155Z" },
{ url = "https://files.pythonhosted.org/packages/fc/3e/a36940844325fbc59adf92116d3bcd1155cc2c72d1a390880886ca492bb4/pyqwest-0.9.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:fdab21bbedff4a21209135423f5f54f287a01dd6143cbd7aeab63d5d8c889654", size = 5246741, upload-time = "2026-08-10T01:54:57.697Z" },
{ url = "https://files.pythonhosted.org/packages/41/8d/efb9741449a81f2489f6929a7691a2145662c3650e06bcb139c64f543d0a/pyqwest-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:21a04ddf4497ef3c7fe36cdc473c094999b7710005d38bd8a0dfd4a3feace12f", size = 5102423, upload-time = "2026-08-10T01:54:59.253Z" },
{ url = "https://files.pythonhosted.org/packages/8f/a8/0b667cf9c07e62861cfe24c09ad30357a9c2a0fc3863cfc5610af5b66e3e/pyqwest-0.9.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8c51c1a27ea529fffab90c127a045f0dfe1abc54d5b9ad5f4165e954439c9b2", size = 5627663, upload-time = "2026-08-10T01:55:01.036Z" },
{ url = "https://files.pythonhosted.org/packages/5f/7c/1f88be3d35afe92b79e361aa40faf3904e3a7852e8638a9d3d5c00891ab3/pyqwest-0.9.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10f283ffc40a2774e0dc8fbb47e4a8f9f8a698a99feae3bf4ad75b340c98031b", size = 5572750, upload-time = "2026-08-10T01:55:03.163Z" },
{ url = "https://files.pythonhosted.org/packages/21/3e/98c1e151772e77dcf00cb8b1c070f2c8edcb15fe12fdee30c1022006ca8e/pyqwest-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f73d4a8e75b5860741fbd0623a0ce792a53336dbfec5f34736004acd5446843", size = 5790615, upload-time = "2026-08-10T01:55:04.758Z" },
{ url = "https://files.pythonhosted.org/packages/7b/f1/12bb4b119cffb7fe5301b2d997ec9ffc1719637f6f4faf2ff2315e13b201/pyqwest-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e34d55a53492b44b82b690c2b11957fc35e99ebab4db2e9bcb2a1e2621d71b62", size = 5976516, upload-time = "2026-08-10T01:55:06.453Z" },
{ url = "https://files.pythonhosted.org/packages/53/55/046dbf68ccedd816770fd212c6c2612372a6f0d7ecf2d8273480db460ddf/pyqwest-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:48c367150783f6866d0af43c209cc405f8287743d7b2472c7d757fda2478fd57", size = 4841470, upload-time = "2026-08-10T01:55:08.035Z" },
{ url = "https://files.pythonhosted.org/packages/55/cd/38ff77d145385afd71b2dbd6eb116251ce3ffddbed0edde16c64394fbec2/pyqwest-0.9.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10fae206270355c993b49c49bf9778995167f5efe591807d50a8e682921d71ea", size = 5222689, upload-time = "2026-08-10T01:55:09.639Z" },
{ url = "https://files.pythonhosted.org/packages/82/95/d8c6804eeb72b7b89842b58f1f05d5cfda5be410c4a22442d2574d510d39/pyqwest-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5f475129fb792593222a9ce0fefbbc10bda1544a7411f3946d771c9a61c50e61", size = 5085846, upload-time = "2026-08-10T01:55:11.569Z" },
{ url = "https://files.pythonhosted.org/packages/f2/e7/04fe8962ec416be0199093ebdd326653a5a7b8f53ddfdb29af81756f7c08/pyqwest-0.9.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78f6f676d349535cb094e8e3bcf33863238a142fb7dcc7afb9e61749e2351047", size = 5613296, upload-time = "2026-08-10T01:55:13.315Z" },
{ url = "https://files.pythonhosted.org/packages/03/0c/7a11728155a34838d2e7c78fb24c937f2cd844ec791ef416030c52f4bb5e/pyqwest-0.9.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f460f5fcdae8cc46e6a679128836f6cd47fbf4bf73e5c9d6ea868e4a90b2e7ee", size = 5557492, upload-time = "2026-08-10T01:55:15.103Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d5/8c6e68f0e978b5257b309809fec00d946c1ed20dacef97f25c23de5b91a9/pyqwest-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cba6804151e973d27c99b9f02b3f0bcab7270c3f99725b508ada9c147365cabd", size = 5774204, upload-time = "2026-08-10T01:55:16.932Z" },
{ url = "https://files.pythonhosted.org/packages/17/20/fed89b62d50b4efad2ebe78c3867e0e402f824726652034728a5dd0a342e/pyqwest-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bc9dfeca326918d70a5368114de0fc627d9f64fafa36563ab484ae58ffe42c0d", size = 5964506, upload-time = "2026-08-10T01:55:19.031Z" },
{ url = "https://files.pythonhosted.org/packages/11/de/30575019efebae84502269d097e706e89a30631e3719218b053e4e0dc179/pyqwest-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384693adc0908f16324e5b508a3c25fb9a92b88f9bdf7ccb6820507db4a95888", size = 4809013, upload-time = "2026-08-10T01:55:21.042Z" },
{ url = "https://files.pythonhosted.org/packages/d0/4e/46822676c7cfafbf510f2af84b510b6f3dc1f3c4df781a837ff75c7410fc/pyqwest-0.9.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c174d8ebde256c457f455dcdedf59446528d84874cb5967041be0d1bda05929", size = 5227559, upload-time = "2026-08-10T01:55:22.625Z" },
{ url = "https://files.pythonhosted.org/packages/cc/80/9d98ad770043e5dd3ad7b765f2acf911bd11b113e0280565277d2e707864/pyqwest-0.9.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d5e56192b03bca7ec511c9af4ed80fa10fc1c1871d0ccfe49050632785b46099", size = 5109747, upload-time = "2026-08-10T01:55:24.393Z" },
{ url = "https://files.pythonhosted.org/packages/36/95/d1a88c8115c88f843b907e3d3bd1958eaef851cca023a47bba4257d974d9/pyqwest-0.9.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3fa3c93638fc645c7c626fe83032f53972673581878bd85702842f41665ad6c", size = 5631597, upload-time = "2026-08-10T01:55:26.19Z" },
{ url = "https://files.pythonhosted.org/packages/5c/3a/e568f9cf9ff75ca45ce7323ac916d076f9f4df4e957404ff0f0d3286dbe0/pyqwest-0.9.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:869d1c630cbff38510207c5b57139f319acca21ed978f9ce6648fcd868df5fbd", size = 5573130, upload-time = "2026-08-10T01:55:28.351Z" },
{ url = "https://files.pythonhosted.org/packages/0a/c9/a261dbf9a4fd3cfa790221957c5cbe0d52e75ff7403dd91254aa977164b1/pyqwest-0.9.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:a0bc2ba9b6d3103c6f0cce59cca9e2e294d1707bca435b471a1917db73010801", size = 5794377, upload-time = "2026-08-10T01:55:30.684Z" },
{ url = "https://files.pythonhosted.org/packages/0b/a4/389a32479767cba432451f2913ff55bcab13981c5cf561028aa54ba97ae1/pyqwest-0.9.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:bfb04e3e91166fe6e4eecdcfccb0b2490da7629984b7988cfd4b11daa1dbb616", size = 5977476, upload-time = "2026-08-10T01:55:32.51Z" },
{ url = "https://files.pythonhosted.org/packages/14/26/91891131d47e1d9ad652ae6ff010bc9169ab051f20d888cd3471d87f070c/pyqwest-0.9.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fbff11bbffb572c084ccd76048c40bd57976b255d34f59280e56626f556cfaeb", size = 4833070, upload-time = "2026-08-10T01:55:34.506Z" },
]
[[package]]