Skip to content

feat(python-sdk): move the volume content client onto pyqwest - #1602

Draft
mishushakov wants to merge 3 commits into
migrate-rest-to-pyqwestfrom
migrate-volume-to-pyqwest
Draft

feat(python-sdk): move the volume content client onto pyqwest#1602
mishushakov wants to merge 3 commits into
migrate-rest-to-pyqwestfrom
migrate-volume-to-pyqwest

Conversation

@mishushakov

@mishushakov mishushakov commented Jul 23, 2026

Copy link
Copy Markdown
Member

What

Stacked on #1601. Migrates the volume content client (Volume/AsyncVolume file operations) onto pyqwest via its httpx-compatible transport adapter — the same ApiPyqwestTransport + connection-retry stack the REST API client uses after #1601.

Originally deferred from #1601 because Volume.read_file(format="stream") relied on httpx's per-read read timeout as an idle timeout, which the adapter can't express per request (it converts the httpx timeout dict into a whole-request deadline, and the sync adapter doesn't bound body reads at all). Unblocked by pyqwest's transport-constructor read_timeout, which maps to reqwest's ClientBuilder::read_timeout — verified behaviorally (local slow-chunk server, sync + async) to be a true per-read idle timeout: it resets after each successful read, covers body reads, and a healthy stream longer than the timeout completes untouched.

Note

Rebased onto #1601, which maps httpx.Proxy onto pyqwest's Proxy object and therefore requires pyqwest 0.8 (released, and locked in #1601). The only change here is following the rename — this PR builds its transport from proxy_to_config(...) instead of proxy_to_url(...).

How

  • e2b/volume/client_sync/__init__.py / client_async/__init__.py move to the same ApiPyqwestTransport + connection-retry stack as the API client. Caches become process-global, keyed by (proxy, streaming) — previously one pool per thread (sync) / per event loop (async).
  • Streamed downloads go through a dedicated streaming transport with read_timeout=60s. It can't live on the shared transport: reqwest's read timer keeps running while a request body is sent and while waiting for the response head (verified empirically — a 2.4 s upload against a 0.5 s read_timeout dies mid-send), so a shared read_timeout would cut off write_file uploads and slow unary responses longer than the idle bound. Uploads and unary calls stay on a transport without it, bounded by their whole-request deadlines as before.
  • The 60 s default matches the JS SDK exactly: JS bounds stream start by requestTimeoutMs (60 s default) and idle gaps by streamIdleTimeoutMs ?? requestTimeoutMs; the Python streaming transport's read_timeout bounds the response head and each idle gap at 60 s, resetting on every chunk, wire-only (a slow consumer doesn't trip it — verified).
  • AsyncVolume.read_file keeps honoring an explicit stream_idle_timeout per call, the same way JS honors streamIdleTimeoutMs and feat(python-sdk): migrate envd RPC to the official connectrpc client #1558 bounds stream setup: asyncio.wait_for around each read (response head and every chunk). Explicit values run on the regular transport, so a value above the 60 s transport bound isn't capped by it and 0 disables idle bounding entirely, restoring the previous contract. The sync client keeps the parameter but ignores it — it has no way to interrupt a blocking read into the Rust transport, so its bound must live in the transport.
  • Streamed reads are sent without a per-request timeout so the adapter imposes no whole-request deadline on long downloads; an explicitly passed request_timeout becomes the total-transfer deadline.
  • pyqwest surfaces a stalled read as the builtin TimeoutError; the stream wrappers remap it to httpx.ReadTimeout, keeping the established contract.
  • Proxy narrowing follows feat(python-sdk): move the REST API client onto pyqwest's httpx transport adapter #1601: str, httpx.URL, and reducible httpx.Proxy values work; inexpressible extras raise InvalidArgumentException.

Testing

  • tests/test_volume_client.py rewritten: process-global transport caching (shared across threads and event loops), streaming vs regular transport separation, plus end-to-end streamed reads through Volume.read_file/AsyncVolume.read_file against a local chunked server — a healthy stream longer than the idle timeout completes (proves the timeout resets per read), a mid-body stall raises httpx.ReadTimeout, a slow response head on a non-streamed read is not cut off by the idle bound, and a slow response head on a streamed read is (JS handshake-timeout parity). Async stream_idle_timeout: an explicit value aborts a stall, a value above the transport bound isn't capped by it, and 0 disables idle bounding.
  • Volume content integration tests couldn't run end-to-end (the test team's key gets 403: use of volumes is not enabled); the mock-transport volume content tests and the local-server stream tests cover that path.
  • Lint (ruff), typecheck (ty), unit suite: green.

Usage example

No API changes for the common path:

volume = Volume.connect(volume_id, token=token)
stream = volume.read_file("big.bin", format="stream")     # stalls bounded by the
for chunk in stream:                                      # transport-wide idle read
    ...                                                   # timeout (httpx.ReadTimeout)

volume.read_file("big.bin", format="stream", stream_idle_timeout=5)  # sync: accepted, ignored

async_volume = await AsyncVolume.connect(volume_id, token=token)
stream = await async_volume.read_file(
    "big.bin", format="stream", stream_idle_timeout=5     # async: honored per read,
)                                                         # 0 disables idle bounding

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b9ff993

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
Replaces the volume content networking stack and changes streaming timeout behavior (idle vs whole-request), which could affect long downloads, stalls, and multi-thread/async pooling despite broad new tests.

Overview
Volume content HTTP (Volume / AsyncVolume file ops) now uses the same pyqwest httpx adapter and connection-retry stack as the REST API client instead of custom httpx transports. Connection pools are cached process-wide per proxy and streaming mode, not per thread or event loop.

Streamed read_file uses a separate transport with a 60s idle read timeout (resets per chunk, raised as httpx.ReadTimeout). Unary reads and uploads stay on a transport without that idle bound. Explicit request_timeout on a stream bounds total transfer time. AsyncVolume.read_file still applies stream_idle_timeout via asyncio.wait_for when set (including 0 to disable); sync read_file documents that parameter as ignored. Tests cover transport sharing, streaming vs regular clients, and timeout behavior against a local chunked server.

Reviewed by Cursor Bugbot for commit b9ff993. 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 b4c90e7. Download artifacts from this workflow run.

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

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

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

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

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

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

Comment thread packages/python-sdk/e2b/volume/volume_async.py
@mishushakov

Copy link
Copy Markdown
Member Author

from other PR:

Non-stream AsyncVolume.read_file still passes FILE_TIMEOUT as the per-request timeout. Through the async pyqwest adapter that is a whole-transfer deadline, so a healthy large bytes/text download can be killed after one hour even though the stream path carefully avoids this.

@linear-code

linear-code Bot commented Jul 30, 2026

Copy link
Copy Markdown

SDK-268

Comment thread packages/python-sdk/e2b/volume/volume_async.py
@mishushakov
mishushakov force-pushed the migrate-volume-to-pyqwest branch from 5e292f9 to 72200f9 Compare July 30, 2026 16:36
mishushakov and others added 3 commits July 31, 2026 16:47
Volume/AsyncVolume file operations join the API client on the
ApiPyqwestTransport + connection-retry stack; transport caches become
process-global keyed by proxy. httpx.Proxy values reduce to plain proxy
URLs; stream_idle_timeout is accepted but a no-op at this point (bounded
by the transport in the next commits).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n a dedicated transport

reqwest's read timer keeps ticking while a request body is sent and while
waiting for the response head, so a shared read_timeout would kill slow
uploads; streamed reads get their own transport with read_timeout=60s
(JS SDK parity), uploads and unary calls stay unbounded-per-read.

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

asyncio.wait_for around each read (response head and every chunk); explicit
values run on the regular transport so values above the 60s transport bound
aren't capped and 0 disables idle bounding. Sync keeps the parameter but
ignores it — it cannot interrupt a blocking read into the Rust transport.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mishushakov
mishushakov force-pushed the migrate-volume-to-pyqwest branch from 72200f9 to b9ff993 Compare July 31, 2026 14:47

@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 b9ff993. Configure here.

# aborted when no bytes at all arrive for this long. It resets on each chunk,
# so it never limits total transfer time — only a fully stalled connection.
# Matches the JS SDK's default stream idle timeout (REQUEST_TIMEOUT_MS).
READ_TIMEOUT: float = 60.0 # 60 seconds

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Wrong default stream idle timeout

Medium Severity

READ_TIMEOUT is 60s and claims JS REQUEST_TIMEOUT_MS parity, but JS Volume.readFile defaults stream handshake/idle to FILE_TIMEOUT_MS (1 hour), and the previous Python path defaulted idle to FILE_TIMEOUT. Streamed volume reads now abort after 60s idle; sync has no way to override that.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b9ff993. Configure here.

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