feat(python-sdk): move the REST API client onto pyqwest's httpx transport adapter - #1601
feat(python-sdk): move the REST API client onto pyqwest's httpx transport adapter#1601mishushakov wants to merge 9 commits into
Conversation
🦋 Changeset detectedLatest commit: fe6b157 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
PR SummaryMedium Risk Overview Pooling and Adapter behavior: custom transports strip httpx’s auto Tests in Reviewed by Cursor Bugbot for commit 3c94acf. Bugbot is set up for automated code reviews on this repo. Configure here. |
Package ArtifactsBuilt from ac82161. Download artifacts from this workflow run. JS SDK ( npm install ./e2b-2.36.2-migrate-rest-to-pyqwest.0.tgzCLI ( npm install ./e2b-cli-2.16.1-migrate-rest-to-pyqwest.0.tgzPython SDK ( pip install ./e2b-2.35.0+migrate.rest.to.pyqwest-py3-none-any.whl |
676f863 to
249a147
Compare
249a147 to
4052953
Compare
4052953 to
937460d
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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.
…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>
fe6b157 to
12a2e7f
Compare
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>
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 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".
| try: | ||
| return super().handle_request(request) | ||
| except TimeoutError as e: | ||
| raise httpx.ReadTimeout(str(e), request=request) from e |
There was a problem hiding this comment.
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 👍 / 👎.
| try: | ||
| return await super().handle_async_request(request) | ||
| except (TimeoutError, asyncio.TimeoutError) as e: | ||
| raise httpx.ReadTimeout(str(e), request=request) from e |
There was a problem hiding this comment.
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 👍 / 👎.

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 andApiClient/AsyncApiClientkeep 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
Proxyobject (pyqwest#194) and request loggers (pyqwest#197). 0.8.0 is now released on PyPI and pinned inpyproject.toml(>=0.8.0,<0.9) withuv.lockrefreshed, souv sync --lockedresolves — the earlier release blocker (SDK-302) is cleared.How
e2b/api/client_sync/__init__.py/client_async/__init__.py:get_transportnow returns a pyqwest-backed httpx transport — aSyncHTTPTransport/HTTPTransport(tls_include_system_certs=True, proxy, pool tuning mapped fromE2B_KEEPALIVE_EXPIRY/E2B_MAX_KEEPALIVE_CONNECTIONS), wrapped in aConnectionRetryTransportfor connect-only retries honoringE2B_CONNECTION_RETRIES, wrapped in theApiPyqwestTransporthttpx adapter.ApiClientsheds its threading machinery: thetransport_factory/async_transport_factoryplumbing, the thread-localhttpx.Clientcache, and the per-loopWeakKeyDictionaryofAsyncClients 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.Clientis 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 overrideclose()/aclose().Hostheader httpx auto-adds, and sending an explicithoston an HTTP/2 connection makes the E2B API edge reset the stream withPROTOCOL_ERROR(reproduced with plain pyqwest againstapi.e2b.app). TheApiPyqwestTransportsubclasses strip it; hyper derivesHost/:authorityfrom the URL. Probably worth an upstream report —pyqwest.httpx'sTRANSPORT_HEADERSstripsconnection/transfer-encoding/etc. but nothost.TimeoutError; the adapter subclasses re-raise it ashttpx.ReadTimeout, preserving thehttpx.TimeoutExceptioncontract for callers.proxy=accepts a URL string,httpx.URL, or anhttpx.Proxy— including its credentials (sent asProxy-Authorization) and any headers configured for the proxy, via pyqwest'sProxyobject.proxy_to_confignormalizes all three into aProxyConfigtuple that both keys the transport cache and builds thepyqwest.Proxy, so the same proxy URL with different credentials or headers gets its own pool. A per-proxyssl_contexthas no counterpart and raisesInvalidArgumentExceptionrather than being silently dropped. (ProxyConfigis aNamedTuple, not a frozen dataclass:tests/test_env_var_parsing.pyreloadse2b.api, and a dataclass__eq__compares class identity, so keys built before and after a reload would silently stop matching.)ProxyTypesis ours now: the public type of theproxyoption (already exported frome2b) used to be imported at runtime from httpx's private_typesmodule in eleven modules. It is defined there asUnion[str, URL, Proxy]— exactly the three forms the SDK's two narrowers accept — so it's spelled out once ine2b.connection_configand 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_urltook a bareobjectwhilee2b.api.proxy_to_configtookOptional[ProxyTypes]; both now say the same thing.isinstancenarrowing 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 clearInvalidArgumentExceptionon a mistyped argument for no dependency savings.pyqwest.accesslogger and lifecycle records onpyqwest, both atDEBUG— the transport-level diagnostics httpcore used to provide, now that httpcore is out of the path. Noted onget_transport; the SDK's ownloggeroption is unchanged and sits above it on the httpx client.http2=Truetransports this replaces.What stays behind (handled by the stacked PRs)
readtimeout as an idle timeout, which the adapter can't express per request — feat(python-sdk): move the volume content client onto pyqwest #1602.Timeout semantics note
request_timeoutwas 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 whatrequest_timeoutpromises.Testing
tests/test_api_client_transport.pyrewritten 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.ReadTimeoutmapping (slow local server), the connection-only retry policy,proxy_to_configconversion, and sync+async round-trips through a real local HTTP server exercising pyqwest end to end.Proxy-Authorization, and the extra proxy header actually arrive, and thepyqwest.accessrecord is asserted for an API call.tests/sync/api_sync,tests/async/api_async, create/kill/timeout/connect — all green. (These initially failed withRemoteProtocolError: StreamResetuntil the Host strip, so they genuinely exercise the new stack.)uv sync --locked, unit suite (tests/*.py, 236 passed),ruff check,ty check— all green.Usage example
No API changes for the common path:
Proxy handling — URL strings and
httpx.Proxyobjects work, credentials and proxy headers included:ProxyTypes— already exported frome2b— is now defined by the SDK rather than re-exported fromhttpx._types, with the same three members:Transport-level HTTP logs, replacing the httpcore records this migration removes:
🤖 Generated with Claude Code