Skip to content

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

Open
mishushakov wants to merge 9 commits into
mainfrom
migrate-rest-to-pyqwest
Open

feat(python-sdk): move the REST API client onto pyqwest's httpx transport adapter#1601
mishushakov wants to merge 9 commits into
mainfrom
migrate-rest-to-pyqwest

Conversation

@mishushakov

@mishushakov mishushakov commented Jul 23, 2026

Copy link
Copy Markdown
Member

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 (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.8 for its Proxy object (pyqwest#194) and request loggers (pyqwest#197). 0.8.0 is now released on PyPI and pinned in pyproject.toml (>=0.8.0,<0.9) with uv.lock refreshed, so uv sync --locked resolves — the earlier release blocker (SDK-302) is cleared.

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 ApiPyqwestTransport 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 AsyncClients 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: the adapter forwards the Host header httpx auto-adds, and sending an explicit host on an HTTP/2 connection makes the E2B API edge reset the stream with PROTOCOL_ERROR (reproduced with plain pyqwest against api.e2b.app). The ApiPyqwestTransport subclasses strip it; hyper derives Host/:authority from the URL. Probably worth an upstream report — pyqwest.httpx's TRANSPORT_HEADERS strips connection/transfer-encoding/etc. but not host.
  • Timeout exceptions: pyqwest raises the builtin TimeoutError; the adapter subclasses re-raise it as httpx.ReadTimeout, preserving the httpx.TimeoutException contract for callers.
  • 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)

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), the Host-header strip (fake inner transport), timeout → httpx.ReadTimeout mapping (slow local server), 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.
  • Two tests cover the pyqwest 0.8 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.
  • Integration against production API (real key): tests/sync/api_sync, tests/async/api_async, create/kill/timeout/connect — all green. (These initially failed with RemoteProtocolError: StreamReset until the Host strip, so they genuinely exercise the new stack.)
  • Re-verified against the released pyqwest 0.8.0 from PyPI (no locally built wheel): uv sync --locked, unit suite (tests/*.py, 236 passed), ruff check, ty check — all green.

Usage example

No API changes for the common path:

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:

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:

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

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

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

@changeset-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fe6b157

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@e2b/python-sdk Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cla-bot cla-bot Bot added the cla-signed label Jul 23, 2026
@cursor

cursor Bot commented Jul 23, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes the default HTTP stack and connection pooling for all E2B API calls (auth, sandbox lifecycle, templates), with subtle differences in timeout semantics and shared clients across threads/loops; envd and volume streaming paths are mostly untouched.

Overview
REST control-plane traffic (sandbox lifecycle, listing, templates, volume API metadata) now goes through pyqwest (PyqwestTransport / AsyncPyqwestTransport) instead of httpx’s native HTTPTransport. The public generated httpx client API is unchanged; only the transport and pooling model change.

Pooling and ApiClient: pyqwest I/O is thread-safe and loop-independent, so sync/async API transports are cached process-wide per proxy (ProxyConfig + proxy_to_config). Per-thread and per-event-loop httpx client caches and transport_factory plumbing are removed; one lazy httpx client serves all callers. Connect failures still retry via E2B_CONNECTION_RETRIES, limited to ConnectionError so requests are not replayed after they may have reached the API.

Adapter behavior: custom transports strip httpx’s auto Host header (avoids HTTP/2 PROTOCOL_ERROR against the E2B API) and map pyqwest timeouts to httpx.ReadTimeout. proxy is normalized for pyqwest (URL / httpx.URL / httpx.Proxy with auth and proxy headers); unsupported httpx.Proxy.ssl_context raises InvalidArgumentException. ProxyTypes moves to connection_config (no private httpx import). pyqwest is bumped to >=0.8.0,<0.9. Envd file-transfer HTTP and volume content clients still use httpx transports; envd RPC is unchanged.

Tests in test_api_client_transport.py are expanded for the new caching, concurrency, proxy, logging, and timeout behavior.

Reviewed by Cursor Bugbot for commit 3c94acf. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Package Artifacts

Built from ac82161. Download artifacts from this workflow run.

JS SDK (e2b@2.36.2-migrate-rest-to-pyqwest.0):

npm install ./e2b-2.36.2-migrate-rest-to-pyqwest.0.tgz

CLI (@e2b/cli@2.16.1-migrate-rest-to-pyqwest.0):

npm install ./e2b-cli-2.16.1-migrate-rest-to-pyqwest.0.tgz

Python SDK (e2b==2.35.0+migrate.rest.to.pyqwest):

pip install ./e2b-2.35.0+migrate.rest.to.pyqwest-py3-none-any.whl

Comment thread packages/python-sdk/e2b/volume/volume_async.py Outdated
Comment thread packages/python-sdk/e2b/volume/volume_async.py Outdated
@linear-code

linear-code Bot commented Jul 29, 2026

Copy link
Copy Markdown

SDK-268

SDK-302

@mishushakov
mishushakov force-pushed the migrate-rest-to-pyqwest branch from 4052953 to 937460d Compare July 29, 2026 17:30

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f95aee3. Configure here.

Comment thread packages/python-sdk/e2b/api/client_sync/__init__.py
mishushakov and others added 7 commits July 30, 2026 18:35
…port adapter

Swap the httpx-native HTTPTransport/AsyncHTTPTransport under the generated
REST API client for pyqwest (Rust reqwest/hyper) via pyqwest.httpx. The
httpx client surface is unchanged; the transports are now thread-safe and
loop-independent, so the pool is cached process-wide per proxy. Strip the
Host header httpx adds — forwarding it on HTTP/2 makes the API edge reset
the stream with PROTOCOL_ERROR. envd traffic and the volume content client
stay on httpx (the streaming download path needs httpx's per-read idle
timeout, which the adapter's whole-request deadline can't express).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iClient

With the pyqwest transports thread-safe and loop-independent, the
transport_factory/async_transport_factory plumbing, the thread-local
httpx.Client cache, and the per-loop WeakKeyDictionary of AsyncClients are
dead weight: a single lazily-created httpx client (the generated base
behavior, same shape the volume client already uses) serves all threads and
event loops over the shared per-proxy pool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Transport identity across threads/loops is implied by the shared-client
tests, sequential-loop reuse by concurrent-loop identity, and the async
request-timeout wiring by the sync test through the now-shared __init__.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The API-client half of the former combined commit: proxy= accepts str,
httpx.URL, and httpx.Proxy when it reduces to a plain proxy URL (auth folded
into the userinfo); inexpressible extras (custom headers, ssl_context) raise
InvalidArgumentException.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
envd RPC already runs on pyqwest via connectrpc (#1558); the envd HTTP API
migration is stacked separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pyqwest raises the builtin TimeoutError for its transport timeouts, which
would break callers catching httpx.TimeoutException; the adapter subclasses
re-raise it as httpx.ReadTimeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Supersedes the earlier reduction of proxies to plain URL strings, now that
pyqwest 0.8 has a Proxy object (curioswitch/pyqwest#194): `proxy_to_url`
becomes `proxy_to_config`, returning the URL, credentials, and proxy headers
as a tuple that both keys the transport caches and builds the `pyqwest.Proxy`.

An `httpx.Proxy`'s custom headers are no longer rejected, and its credentials
go out as `Proxy-Authorization` instead of being percent-encoded back into the
URL userinfo. A per-proxy `ssl_context` still has no counterpart and is still
rejected. The transport cache keys on the whole configuration, so the same
proxy URL with different credentials or headers gets its own pool.

`ProxyConfig` is a NamedTuple rather than a frozen dataclass because
`tests/test_env_var_parsing.py` reloads `e2b.api`: a dataclass `__eq__`
compares class identity, so keys built before and after a reload would
silently stop matching.

pyqwest 0.8 also logs requests itself on the `pyqwest.access` and `pyqwest`
loggers at DEBUG, restoring the transport-level diagnostics httpcore used to
provide; noted on `get_transport` and covered by a test, with the SDK's own
`logger` option unchanged above it.

Co-Authored-By: Claude <noreply@anthropic.com>
@mishushakov
mishushakov force-pushed the migrate-rest-to-pyqwest branch from fe6b157 to 12a2e7f Compare July 30, 2026 16:36
mishushakov and others added 2 commits July 31, 2026 16:28
The branch's pyproject already requires `pyqwest>=0.8.0,<0.9` for the Proxy
object and the multipart fix; refresh the lockfile now that 0.8.0 is on PyPI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ProxyTypes` — the public type of the `proxy` option — came from httpx's
private `_types` module, imported at runtime in eleven modules. It is defined
there as `Union["URL", str, "Proxy"]`, exactly the three forms the SDK's two
narrowers accept, so spell it out once in `e2b.connection_config` and import
it from there instead. Same public name, same type to a type checker, no
private-module dependency, and a place for a pyqwest proxy type to land as the
transports move off httpx.

`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. Keeping `isinstance` narrowing rather than duck-typing `.url`/`.auth`
is deliberate: httpx is a required dependency here (the generated REST client
is an httpx client and envd file transfers use httpx directly), so probing
attributes would trade a clear `InvalidArgumentException` on a mistyped
argument for no dependency savings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mishushakov
mishushakov marked this pull request as ready for review July 31, 2026 19:33

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization's overage spend limit has been reached.

Code review is billed via overage credits. To resume reviews, an organization admin can raise the monthly limit at claude.ai/admin-settings/claude-code.

Once credits are available, reopen this pull request to trigger a review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3c94acf8df

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +45 to +48
try:
return super().handle_request(request)
except TimeoutError as e:
raise httpx.ReadTimeout(str(e), request=request) from e

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wrap response-stream timeouts in the sync transport

When the API or a proxy sends response headers and then stalls the body, super().handle_request() has already returned an httpx.Response, so this except TimeoutError cannot translate the timeout that happens while httpx.Client.request() consumes response.stream. That regresses the generated client's documented httpx.TimeoutException contract for normal non-streaming REST calls, and for the sync adapter may also leave body reads outside the per-request timeout; wrap the returned byte stream or otherwise keep timeout translation active through body consumption.

Useful? React with 👍 / 👎.

Comment on lines +49 to +52
try:
return await super().handle_async_request(request)
except (TimeoutError, asyncio.TimeoutError) as e:
raise httpx.ReadTimeout(str(e), request=request) from e

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wrap response-stream timeouts in the async transport

When a response body is slow after headers arrive, the pyqwest adapter raises the timeout while httpx.AsyncClient later iterates the returned response stream, after this try block has completed. In that scenario callers see a bare TimeoutError/asyncio.TimeoutError instead of the httpx.ReadTimeout promised here and caught elsewhere as httpx.TimeoutException; the stream returned from the adapter needs the same translation.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant