Skip to content

refactor(python-sdk): unify the pyqwest connection pools - #1659

Closed
mishushakov wants to merge 1 commit into
mainfrom
python-sdk-unify-pyqwest-connection-pools-once-all-http-sdk-291
Closed

refactor(python-sdk): unify the pyqwest connection pools#1659
mishushakov wants to merge 1 commit into
mainfrom
python-sdk-unify-pyqwest-connection-pools-once-all-http-sdk-291

Conversation

@mishushakov

@mishushakov mishushakov commented Aug 11, 2026

Copy link
Copy Markdown
Member

Every persistent HTTP stack in the Python SDK — control-plane REST, the envd HTTP API, the envd RPC clients, and the volume content API — now draws its connection pool from e2b.api.client_sync/client_async keyed on (proxy, idle read bound), instead of each caching one of its own; reqwest pools per host internally, so one pool serves the API host and every per-sandbox host without interference, and because envd RPC and the envd HTTP API hit the same host an active sandbox needs a single HTTP/2 connection instead of one per stack. Two accessors expose it (get_pyqwest_transport for connectrpc, get_httpx_transport for the generated httpx clients) while per-layer concerns stay above the pool, so PlainHTTPErrorTransport becomes a stateless per-client wrapper and Connect-error normalization stays RPC-only. Streamed downloads keep a pool of their own — the only one carrying the idle read_timeout, since reqwest's read timer runs during body send and TTFB and would otherwise cut off long uploads.

Sharing puts the sandbox health probe on the connection the failed RPC was using, so tests/test_shared_transport_pool.py pins that at the frame level with a new multi-connection HTTP/2 server serving both routes on one pool: an RST_STREAM kills only the stream and the probe reuses the same connection (which is also the proof the pool is genuinely shared), while a dropped TCP connection makes reqwest redial — both still answer, so handle_rpc_exception_with_health keeps telling a wedged connection apart from a dead sandbox.

No user-facing API change, so there are no usage examples to add — the public surface, timeouts, retry policy, and proxy handling are all unchanged, and JS has no counterpart since pyqwest pools are Python-only.

Closes SDK-291.

Notes for review

  • Template context uploads deliberately stayed on their own throwaway transport: pyqwest's retry middleware mirrors every non-bytes request body in RAM to make it replayable (verified — a 16 MiB streamed PUT buffered 16 MiB), so joining the shared retrying pool would buffer whole build contexts. That's a pre-existing bug on main for volume uploads and envd files.write — filed as SDK-332 with the repro and two verified fixes; note the obvious fix doesn't work, because gating retries on the body type would silently strip connect retries from every envd RPC (connectrpc hands pyqwest a generator even for unary calls).
  • RPC and envd HTTP now multiplex on one HTTP/2 connection and share its concurrent-stream budget (Go's default is 250, and hyper dials a second connection when one saturates) — low risk, but a real behavior change under heavy per-sandbox concurrency.

🤖 Generated with Claude Code

@linear-code

linear-code Bot commented Aug 11, 2026

Copy link
Copy Markdown

SDK-291

@cla-bot cla-bot Bot added the cla-signed label Aug 11, 2026
@changeset-bot

changeset-bot Bot commented Aug 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6d3e6df

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 Patch

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

@cursor

cursor Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

PR Summary

Medium Risk
Sharing one HTTP/2 connection per sandbox between RPC and HTTP multiplexes concurrent streams and changes connection reuse after transport failures; public signatures are unchanged but behavior under heavy per-sandbox concurrency shifts.

Overview
The control-plane REST API, envd HTTP API, envd RPC clients, and volume content API no longer maintain separate pyqwest pools. They share process-global caches in e2b.api.client_sync/client_async via get_pyqwest_transport and get_httpx_transport, keyed on (proxy, read_timeout, http2). get_envd_transport is now an alias of get_transport for the same pool per key.

Envd RPC drops its per-proxy transport cache; PlainHTTPErrorTransport wraps the shared pool per client instead. Streamed downloads still use a separate pool when for_streaming=True applies READ_TIMEOUT as an idle read bound.

Tests add transport_caches.reset_transport_caches, SharedPoolServer frame tests for RPC plus /health on one pool, and updates asserting envd and API transport identity.

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

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Package Artifacts

Built from 7b840e8. Download artifacts from this workflow run.

JS SDK (e2b@2.40.1-python-sdk-unify-pyqwest-connection-pools-once-all-http-sdk-291.0):

npm install ./e2b-2.40.1-python-sdk-unify-pyqwest-connection-pools-once-all-http-sdk-291.0.tgz

CLI (@e2b/cli@2.16.3-python-sdk-unify-pyqwest-connection-pools-once-all-http-sdk-291.0):

npm install ./e2b-cli-2.16.3-python-sdk-unify-pyqwest-connection-pools-once-all-http-sdk-291.0.tgz

Python SDK (e2b==2.40.0+python.sdk.unify.pyqwest.connection.pools.once.all.http.sdk.291):

pip install ./e2b-2.40.0+python.sdk.unify.pyqwest.connection.pools.once.all.http.sdk.291-py3-none-any.whl

@mishushakov
mishushakov marked this pull request as ready for review August 11, 2026 11:04

@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.

I reviewed this PR and didn't find any bugs. Because it's a large, cross-cutting refactor of the Python SDK's connection-pooling internals that changes runtime sharing behavior across the REST, envd RPC, envd HTTP, and volume stacks, a human look would still be worthwhile.

What was reviewed:

  • Cache-key correctness for the unified transport dicts (proxy, idle read bound) across sync/async and all four consumers (API, envd HTTP, envd RPC, volume).
  • The two-step lock pattern in get_httpx_transport (resolve pool, then check/populate the httpx-adapter cache) — not a race since each dict access is independently locked and the operations are idempotent.
  • New frame-level HTTP/2 tests (test_shared_transport_pool.py) validating that a health probe still succeeds after an RPC-stream reset (shared connection reuse) and after a full connection drop (redial).
Extended reasoning...

Overview

This PR consolidates four previously-separate pyqwest connection pools (control-plane REST API, envd HTTP API, envd RPC clients, and volume content API) into one process-global pool per (proxy, idle read bound) key, shared via get_pyqwest_transport/get_httpx_transport in e2b.api.client_sync/client_async. It removes per-stack transport caches from e2b/envd/client_async, e2b/envd/client_sync, and e2b/volume/client_*, replacing them with calls into the shared accessors. PlainHTTPErrorTransport becomes a stateless per-client wrapper instead of being cached alongside the pool. Streamed downloads keep a dedicated pool carrying the idle read timeout. Tests are substantially reworked: a new transport_caches.py helper resets all caches, and a new test_shared_transport_pool.py adds a real multi-connection plaintext HTTP/2 server to verify that RPC and envd HTTP now share one HTTP/2 connection per sandbox, and that health probes still succeed whether the shared connection survives (RST_STREAM) or is dropped entirely.

Security risks

None identified. No auth, crypto, or permission logic changes — this is purely about how many TCP/HTTP2 connections are opened and reused. Proxy credentials and headers remain part of the cache key (verified via existing tests), so proxy configs aren't cross-contaminated between differently-configured clients.

Level of scrutiny

This warrants a higher bar than a typical patch: it rewires the connection-pooling layer underneath every HTTP and RPC call the SDK makes (sandbox creation, file I/O, process RPC, volume access). The PR description itself flags a real behavioral change — RPC and envd HTTP now multiplex on one HTTP/2 connection and share its concurrent-stream budget, which could matter under heavy per-sandbox concurrency. That's the kind of tradeoff a maintainer with production traffic context should sign off on, even though the mechanics look correct and are well covered by tests.

Other factors

The test suite is extensive and specifically targets the riskiest part of the change (shared-connection health probing after a broken RPC stream) with a real socket-level HTTP/2 server rather than mocks, which gives good confidence in the specific scenario called out in the description. The PR also transparently documents a pre-existing, unrelated bug it deliberately does not fix (retry-buffering of streamed upload bodies), filed separately as SDK-332 — that's out of scope here and not a reason to block this PR.

@mishushakov
mishushakov enabled auto-merge (squash) August 11, 2026 11:39
@mishushakov
mishushakov disabled auto-merge August 11, 2026 11:42
@mishushakov
mishushakov enabled auto-merge (squash) August 11, 2026 11:42
@mishushakov
mishushakov force-pushed the python-sdk-unify-pyqwest-connection-pools-once-all-http-sdk-291 branch from ad9eb51 to 3141555 Compare August 13, 2026 23:15
Every persistent HTTP stack in the SDK now draws its connection pool from
`e2b.api.client_sync`/`client_async`, keyed on (proxy, idle read bound),
instead of caching four of its own: the control-plane REST API, the envd
HTTP API, the envd RPC clients, and the volume content API. reqwest pools
per host internally, so one pool serves the API host and every per-sandbox
host without interference — and because envd RPC and the envd HTTP API hit
the same host, an active sandbox needs a single HTTP/2 connection instead
of one per stack.

Two accessors expose it: `get_pyqwest_transport` hands connectrpc the
pool behind the connect-only retries, and `get_httpx_transport` hands the
generated httpx clients the `PyqwestTransport` adapter over that same
pool. Layers above stay per-consumer, as the design calls for:
`PlainHTTPErrorTransport` is now a stateless per-client wrapper rather
than a cached transport, so Connect-error normalization stays RPC-only.
Streamed downloads keep a pool of their own — the only one carrying the
idle `read_timeout`, since reqwest's read timer runs during body send and
TTFB and would otherwise cut off long uploads.

Sharing puts the sandbox health probe on the connection the failed RPC was
using, so `tests/test_shared_transport_pool.py` pins that at the frame
level with a new multi-connection HTTP/2 server serving both routes on one
pool: an RST_STREAM kills only the stream and the probe reuses the same
connection, while a dropped TCP connection makes reqwest redial. Both
paths still answer, so `handle_rpc_exception_with_health` keeps telling a
wedged connection apart from a dead sandbox.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mishushakov
mishushakov force-pushed the python-sdk-unify-pyqwest-connection-pools-once-all-http-sdk-291 branch from 3141555 to 6d3e6df Compare August 19, 2026 13:53
@mishushakov

Copy link
Copy Markdown
Member Author

/sdk claim

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claimed. This PR's commit was cloned onto cursor/pr-claiming-mechanism-1180 and reopened as #1692, with authorship and the Co-Authored-By trailer preserved.

While claiming I re-ran the checks (264 hermetic unit tests, 128 in tests/shared, plus format/lint/typecheck — all clean) and added one regression test the original was missing: nothing asserted that closing one httpx client leaves the now process-global shared pool intact, even though pyqwest pools are closable and every httpx client holds the same cached adapter over one. Details are in #1692.

Please close this PR in favour of #1692 — I have no write access to close it myself.

View PR

Open in Web View Automation 

Sent by Cursor Automation: /claim Claim SDK PR by SDK Factory

auto-merge was automatically disabled August 19, 2026 14:13

Pull request was closed

mishushakov added a commit that referenced this pull request Aug 19, 2026
Claimed from #1659 on `/sdk claim` by the PR's own author (@mishushakov,
org member). The original commit is carried over untouched, so
authorship and the `Co-Authored-By` trailer are preserved — only PR
ownership moves. **Please close #1659 in favour of this PR** (`Closes`
does not auto-close pull requests, and this automation has no write
access to do it).

Closes
[SDK-291](https://linear.app/e2b/issue/SDK-291/python-sdk-unify-pyqwest-connection-pools-once-all-http-traffic-is-off).

## What changes

Every persistent HTTP stack in the Python SDK — control-plane REST, the
envd HTTP API, the envd RPC clients, and the volume content API — now
draws its connection pool from `e2b.api.client_sync`/`client_async`
keyed on `(proxy, idle read bound, HTTP version)`, instead of each
caching one of its own; reqwest pools per host internally, so one pool
serves the API host and every per-sandbox host without interference, and
because envd RPC and the envd HTTP API hit the same host an active
sandbox needs a single HTTP/2 connection instead of one per stack. Two
accessors expose it (`get_pyqwest_transport` for connectrpc,
`get_httpx_transport` for the generated httpx clients) while per-layer
concerns stay above the pool, so `PlainHTTPErrorTransport` becomes a
stateless per-client wrapper and Connect-error normalization stays
RPC-only. Streamed downloads keep a pool of their own — the only one
carrying the idle `read_timeout`, since reqwest's read timer runs during
body send and TTFB and would otherwise cut off long uploads.

Sharing puts the sandbox health probe on the connection the failed RPC
was using, so `tests/test_shared_transport_pool.py` pins that at the
frame level with a new multi-connection HTTP/2 server serving both
routes on one pool: an `RST_STREAM` kills only the stream and the probe
reuses the same connection (which is also the proof the pool is
genuinely shared), while a dropped TCP connection makes reqwest redial —
both still answer, so `handle_rpc_exception_with_health` keeps telling a
wedged connection apart from a dead sandbox.

**No user-facing API change**, so there are no usage examples to add —
the public surface, timeouts, retry policy, and proxy handling are all
unchanged, and JS has no counterpart since pyqwest pools are
Python-only.

## Added while claiming

One regression test the original was missing
(`test_{sync,async}_closing_one_client_leaves_the_shared_pool_open` in
`tests/test_api_client_transport.py`). The refactor's docstrings promise
that "closing an httpx client leaves the pool intact for the other
clients on it", and that promise is now load-bearing process-wide rather
than per-stack, but nothing asserted it: pyqwest pools *are* closable
(`SyncHTTPTransport.close`/`HTTPTransport.aclose`) and every httpx
client in the SDK holds the same cached adapter over one. The existing
tests all close their clients inside `finally` and then reset the
caches, so a close that reached the pool would go unnoticed.

The new tests round-trip against the local echo server, then close the
control-plane client and assert that both a sibling client (the envd
HTTP API) and the pool the envd RPC stack executes on directly still
work. Verified in the pinned dependency that
`PyqwestTransport`/`AsyncPyqwestTransport` inherit httpx's no-op
`close`/`aclose` and never touch the wrapped pool, and confirmed the
assertions are not vacuous: forwarding the adapter's `close()` to the
pool makes both of them fail with `RuntimeError: Executing request on
already closed transport`.

## Verification

- `uv run pytest tests/*.py -q` — 264 passed (262 before the added
test).
- `uv run pytest tests/shared -q` — 128 passed, 1 skipped.
- `pnpm run format`, `pnpm run lint`, `pnpm run typecheck` — clean.
- Checked the two claims from the original description that a reader
would have to take on trust: pyqwest's retry middleware does mirror
non-`bytes` request bodies in RAM (`RetryingRequestContent` accumulates
every chunk into a `bytearray` to make the body replayable), which is
why template context uploads deliberately keep their own non-retrying
transport — and why the same buffering applies to volume uploads and
envd `files.write` on the shared retrying pool, a pre-existing issue on
`main` filed as
[SDK-332](https://linear.app/e2b/issue/SDK-332/python-sdk-streamed-uploads-are-mirrored-in-ram-by-the-pyqwest-retry)
rather than something this PR introduces.

## Notes for review

- RPC and envd HTTP now multiplex on one HTTP/2 connection and share its
concurrent-stream budget (Go's default is 250, and hyper dials a second
connection when one saturates) — low risk, but a real behavior change
under heavy per-sandbox concurrency.
- `get_envd_transport` survives only as an alias of `get_transport`
because external consumers (`e2b-code-interpreter`) call it; prefer
`get_transport` inside the SDK.
- The changeset from the original PR is carried over unchanged
(`@e2b/python-sdk` patch); the added test needs none of its own.

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

<div><a
href="https://cursor.com/agents/bc-bfb51e4b-1116-42f4-8622-ba3bdeacf11a?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/automations/3b1a5376-9bd3-11f1-ba66-0e7d0216e441"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/view-automation-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/view-automation-light.png"><img
alt="View Automation" width="141" height="28"
src="https://cursor.com/assets/images/view-automation-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Mish Ushakov <10400064+mishushakov@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Mish Ushakov <mishushakov@users.noreply.github.com>
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