fix(python-sdk): keep streamed uploads off the retrying transport - #1661
Conversation
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>
PR SummaryMedium Risk Overview Transport layering is refactored in sync/async Call sites are wired to the upload path: sandbox filesystem gets Reviewed by Cursor Bugbot for commit 0d6033d. Bugbot is set up for automated code reviews on this repo. Configure here. |
Package ArtifactsBuilt from 93f6c21. Download artifacts from this workflow run. JS SDK ( npm install ./e2b-2.38.4-fix-retry-body-buffering.0.tgzCLI ( npm install ./e2b-cli-2.16.2-fix-retry-body-buffering.0.tgzPython SDK ( pip install ./e2b-2.38.0+fix.retry.body.buffering-py3-none-any.whl |
8c57676 to
3754b57
Compare
🦋 Changeset detectedLatest commit: 0d6033d 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 |
To be able to replay a request, pyqwest's retry middleware copies a streamed request body in full as it is sent, so uploads were streamed to the wire *and* held whole in RAM (64 MiB file -> 70 MB peak). The copy is what makes the retries work — reqwest reads ahead into the body while connecting, so a connect error leaves the iterator already started and unrewindable — so uploads skip the layer rather than the layer skipping the copy. `get_upload_transport` is the httpx adapter over the same pool without the retries, and envd `files.write`, `volume.write_file` and template context uploads take it whenever their body is streamed. Sharing the pool keeps the connection reuse (and lets template uploads stop building a pool per build); what a streamed upload gives up is the connect retry, which fires before any of the body was written. Writes of in-memory data stay on the retrying transport, where a `bytes` body is replayed without a copy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3754b57 to
0d6033d
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 0d6033d. Configure here.
| # An upload streams its body, and the connect retries would make that | ||
| # body replayable by copying it in full — the whole file in memory | ||
| # (SDK-332) — so it goes out on the non-retrying client instead. | ||
| api_client = get_upload_volume_api_client(config) |
There was a problem hiding this comment.
Volume writes drop in-memory retries
Medium Severity
write_file always uses get_upload_volume_api_client, including for str/bytes bodies that reach the transport as bytes and need no replay copy. Those calls lose connect retries, unlike files.write and contrary to the changeset note that in-memory writes keep them.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 0d6033d. Configure here.
ad9eb51 to
3141555
Compare


Stacked on #1659.
pyqwest.middleware.retrymakes a streamed request body replayable by growing abytearraycopy of it as the body is sent, for every body that isn't alreadybytes. So an upload was streamed to the wire and mirrored whole in RAM. Measured onmainwithvolume.write_file(path, file_object)and a 64 MiB file:Content-Length: 67108864with no chunked encoding and the server reading incrementally — genuinely streamed — while 67,108,864 bytes landed inRetryingRequestContent._buffer, for a traced peak of 70 MB. The copy is a tee rather than a replacement for streaming, which is why nothing involume_sync.pyor the filesystem write path looks wrong.The copy is what makes the retries work, so it can't just be removed. reqwest reads ahead into its own body channel while the connection is being established, so a connect error surfaces with the iterator already started and nothing to rewind. Measured against a refused port on pyqwest 0.9.0, chunks pulled from the body before
ConnectionError:That last row is why the retry layer has to stay for RPC traffic: connectrpc hands pyqwest a generator even for unary calls, and without the copy every envd RPC would silently lose its connect retries.
So the uploads move instead of the copy.
get_upload_transportis the httpx adapter over the same connection pool with the retry layer left out, and the three upload paths take it when their body is streamed:files.write— a third sibling client next to the retrying and streaming ones (_envd_api_upload), used on both the octet-stream and multipart paths.volume.write_file—get_upload_api_client, the volume content client on that transport.Sharing the pool is the point: an upload still travels the sandbox's or the volume host's pooled connections, and a health probe after a failed RPC still lands on the same connection. What a streamed upload gives up is the connect retry, which fires before any of the body was written, so the caller sees the connection error intact and can retry the upload itself. Writes of in-memory data (
files.write(path, "text"),bytes) stay on the retrying transport — abytesbody is replayed without a copy, so those retries cost nothing and are kept.No user-facing API change, so there are no usage examples to add: same public surface, same timeouts, no new options. JS has no counterpart — undici streams request bodies without buffering and the retry middleware is pyqwest-only.
Closes SDK-332.
Verification
tests/test_upload_transport.py(new, 12 tests): a 32 MiB streamed upload arrives whole with Content-Length framing and stays under 8 MiB of traced peak allocations (measured 1.5 MB, against 38 MB on the retrying transport); the upload transport wraps the very same pool object with no retry layer and is cached per proxy and read bound; the envd filesystem's three clients sit on the three transports; and awriteof in-memory data still goes to the retrying client while a file-like one goes to the upload client, on both the octet-stream and multipart paths.tests/test_volume_client.py: the volume upload transport is the SDK-wide upload transport, distinct from the volume client's default one.files/suites, sync and async (123 tests, in-memory, streamed octet-stream and multipart writes), andtemplate_sync/template_asynctest_build.pywithforce_upload=True, which puts a real build context through the presigned S3 PUT on the shared pool.rufflint/format andtytypecheck are green. The JS checks were not run — no JS or TS files are touched.Notes for review
get_pyqwest_transport(the retrying stack, used by the envd RPC clients) is unchanged in behavior; it's now built on top ofget_pool, which is the pool cache split out so the retry layer can be skipped without losing the connections.execute_syncis@final— more machinery than the problem deserves.max_buffered_body_sizeupstream in pyqwest's middleware anyway, so other consumers don't pay an unbounded copy.🤖 Generated with Claude Code