## 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](https://github.com/curioswitch/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.9** — pinned in `pyproject.toml` (`>=0.9.0,<0.10`)
with `uv.lock` refreshed. 0.8 brought the `Proxy` object
([pyqwest#194](https://github.com/curioswitch/pyqwest/pull/194)) and
request loggers
([pyqwest#197](https://github.com/curioswitch/pyqwest/pull/197)); 0.9
([release
notes](https://github.com/curioswitch/pyqwest/discussions/214)) folds
the two adapter workarounds this PR used to carry into the adapter
itself and makes redirect handling configurable, so the SDK no longer
subclasses the adapter at all.
## 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 stock
`PyqwestTransport`/`AsyncPyqwestTransport` 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
`AsyncClient`s 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** (upstream in 0.9): sending the `Host` header httpx
auto-adds on an HTTP/2 connection makes the E2B API edge reset the
stream with `PROTOCOL_ERROR` (reproduced with plain pyqwest against
`api.e2b.app`); hyper derives `Host`/`:authority` from the URL. The
adapter now skips a `host` header matching the URL, so the SDK-side
strip is gone — and unlike that strip, a genuinely custom `Host`
override is still forwarded.
- **Timeout exceptions** (upstream in 0.9): pyqwest raises the builtin
`TimeoutError`; the adapter maps it to `httpx.ReadTimeout` both while
awaiting the response head and while reading the body, preserving the
`httpx.TimeoutException` contract for callers. Connection, network, and
protocol failures likewise arrive as
`httpx.ConnectError`/`ConnectTimeout`, `httpx.ReadError`/`WriteError`,
and `httpx.RemoteProtocolError` instead of leaking pyqwest/builtin
types.
- **Redirects**: the pyqwest transports are built with
`follow_redirects=False` (0.9 made it configurable; reqwest's default is
to follow). Otherwise redirects are followed inside the transport,
hiding 3xx responses from httpx and leaving `response.history` empty —
even though the generated clients ask for no redirect following. httpx
owns them again, as with the transports this replaced.
- **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)
- **envd HTTP API client** (file transfers, health checks): #1623, which
also dedupes the transport plumbing this PR and #1558 each carry a copy
of (the proxy narrowing, pool tuning, retry transport — envd keeps
byte-identical duplicates until then).
- **Volume content client**: its streaming download relies on httpx's
per-read `read` timeout as an *idle* timeout, which the adapter can't
express per request — #1602.
- **Template build context upload**: one-off httpx client PUTing to S3
presigned URLs — #1603.
## 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), timeout → `httpx.ReadTimeout` mapping for both the response
head and a stalled body (slow/stalling local server), redirects
surfacing to httpx (302 returned as-is, `response.history` populated
when the caller opts in), 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. The two host-header
unit tests are gone with the subclasses they tested — that behavior is
the adapter's now.
- Two tests cover the pyqwest proxy/logging 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.
- On pyqwest 0.9.0 from PyPI: `uv sync --locked`, unit suite
(`tests/*.py`, 238 passed), `ruff check`, `ty check` — all green.
- Integration against the production API (real key) was run on 0.8.0:
`tests/sync/api_sync`, `tests/async/api_async`,
create/kill/timeout/connect — all green. (These initially failed with
`RemoteProtocolError: StreamReset` until the host header stopped being
forwarded, so they genuinely exercise the new stack; that fix now comes
from the adapter.)
## Usage example
No API changes for the common path:
```python
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:
```python
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:
```python
from e2b import ProxyTypes # Union[str, httpx.URL, httpx.Proxy]
```
Transport-level HTTP logs, replacing the httpcore records this migration
removes:
```python
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](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The specs in `spec/` were copied from their source repos by hand and had
drifted ~2,400 lines behind infra, so they are now imported with
Copybara (`copy.bara.sky`, run in a pinned Docker image by
`scripts/fetch-spec.sh`): `make codegen` re-fetches them at the commits
pinned in `spec/infra-ref` and `spec/belt-ref` before generating, and
the generated-files CI check fails if the tracked copies don't match the
pins. Regenerating from the current pins picks up the accumulated spec
changes in the generated JS/Python clients (renamed request schemas,
`SandboxNetworkConfig`, `SandboxIam` workload identity,
`FILE_TYPE_SYMLINK`, access-token auth deprecation, volume path-metadata
tweaks). The one handwritten SDK change follows from that: the public
`FileType` enums gain a `SYMLINK` member (JS and both Python surfaces)
so entries envd reports as symlinks show up in `files.list()` and
`getInfo()`/`get_info()` instead of being silently skipped as unknown
types. The custom `spec/remove_extra_tags.py` tag-filtering script is
replaced by Redocly CLI's `filter-in` decorator (`redocly.yaml`), which
produces identical generated JS output; a `filter-out` decorator
additionally drops any operation or component schema the upstream specs
mark `x-not-implemented: true` (currently the SOCKS5
`SandboxEgressProxyConfig`/`egressProxy` surface, which infra flagged as
spec-only); each SDK's bundle now goes to its own gitignored
`spec/openapi_generated.<api>.yml` instead of both pipelines overwriting
one shared file; Python client models now list fields in spec order
instead of alphabetical (mechanical reordering only — construct models
with keyword args). Spec fetches try whatever GitHub token is available
and fall back to the tracked copies with a warning (the public infra
specs also fetch anonymously); in CI a short-lived belt-scoped token is
minted from the org-wide Autofixer GitHub App (no new secrets), so fork
PRs simply fall back for the belt spec; the CI workflows also cache the
Copybara image alongside the codegen image, and the previously ignored
`CODEGEN_IMAGE` env is honored by the Makefile.
## Usage
```sh
# update the specs: bump a pin, then regenerate
echo <infra-commit-sha> > spec/infra-ref
make codegen
# fetch a single spec without regenerating
pnpm fetch:api-spec # spec/openapi.yml from infra
pnpm fetch:envd-spec # spec/envd/ from infra
pnpm fetch:volume-spec # spec/openapi-volumecontent.yml from belt
# try the latest spec without touching the pin
E2B_INFRA_REF=main pnpm fetch:api-spec
# change which endpoint tags an SDK exposes
$EDITOR redocly.yaml && make codegen
```
```ts
// symlinks are now visible in the filesystem API (JS; same shape in Python)
const entries = await sandbox.files.list('/home/user')
const link = entries.find((e) => e.type === FileType.SYMLINK)
console.log(link?.symlinkTarget)
```
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Replaces the vendored `e2b_connect` client and the custom Go
`protoc-gen-connect-python` plugin with the official Connect RPC client
for Python ([`connectrpc`](https://github.com/connectrpc/connect-py),
transport: `pyqwest`/Rust hyper), and switches the envd messages from
Google's `protobuf` runtime to Buf's
[`protobuf-py`](https://github.com/bufbuild/protobuf-py) (which
`connectrpc` already requires) — the SDK no longer depends on the
conflict-prone `protobuf` package at all, and the protoc binary drops
out of the codegen image. The wire format (same protos, same JSON) is
unchanged. Closing a command or watch stream early now sends
`RST_STREAM`, fixing abandoned streams leaking on the shared HTTP/2
connection, and peer resets surface as typed `ConnectError`s. The
plumbing mirrors the `e2b.api` layout: shared pieces (a JSON codec that
ignores unknown response fields, proxy narrowing, pool tuning) live in
`e2b/envd/client_shared.py`, the flavor-specific pyqwest transports
(wrapped in pyqwest's retry middleware, see the retry note below) and
`create_rpc_client` factories in `e2b/envd/client_sync/` and
`e2b/envd/client_async/`, and the default-header/logging interceptors in
`e2b/envd/interceptors.py`; `e2b/envd/rpc.py` maps `connectrpc` error
codes onto the existing SDK exceptions, so the public API is unchanged
(`sandbox.commands.run(...)`, `files.watch_dir(...)`, etc. work exactly
as before). The REST API and file upload/download keep using `httpx`.
The `proxy` connection option now applies to sandbox RPC calls too —
[pyqwest
0.7.0](https://github.com/curioswitch/pyqwest/releases/tag/v0.7.0) added
an httpx-style `proxy` parameter to its transports, so commands, PTY,
and filesystem watch traffic follow the same proxy as the REST API and
file transfers (an earlier revision of this PR could only fall back to
`http_proxy`/`https_proxy` env vars for RPC):
```python
sandbox = Sandbox.create(proxy="http://user:pass@localhost:8030")
# REST *and* RPC (commands, PTY, watch) traffic goes through the proxy
result = sandbox.commands.run("echo through-the-proxy")
```
Notes:
- `e2b_connect` is no longer shipped in the wheel; code importing it
directly should switch to `connectrpc` (`ConnectError`, `Code`) — SDK
exception types are unchanged.
- The generated `e2b.envd.*.*_pb2` modules are replaced by `protobuf-py`
equivalents (`process_pb`, `filesystem_pb`) with a different message API
(`Oneof` objects, `has_field`); these are internal modules —
`e2b-code-interpreter` and `e2b-desktop` were verified not to import
them.
- RPC transports are cached per proxy URL. `httpx.URL` and `httpx.Proxy`
proxies keep working for RPC calls when they reduce to a proxy URL
(`httpx.Proxy` auth is folded back into the URL userinfo); `httpx.Proxy`
extras that pyqwest can't express — custom headers, an `ssl_context` —
raise `InvalidArgumentException` rather than being silently dropped.
- Plain (non-Connect-encoded) HTTP error responses — an edge proxy or
gateway answering for envd — keep the vendored client's status mapping
even when they carry a JSON body that isn't a valid Connect error (e.g.
a gateway's `{"code": 429}` raises `RateLimitException`, not a
misleading sandbox-timeout); only JSON bodies with a valid Connect
`code` string are left to connectrpc to parse. An envd response that
fails to decode surfaces as a `SandboxException` with a clear message —
the SDK's JSON codec raises a typed `ConnectError(INTERNAL)` at the
source (connectrpc re-raises codec-raised `ConnectError`s unchanged),
rather than the error being reconstructed from `__cause__` heuristics in
the exception mapper.
- pyqwest 0.7.0 explicit transports default to an **empty TLS root
store** (0.6.2 used reqwest's defaults), so the envd transports pass
`tls_include_system_certs=True`; the dependency floor is
`pyqwest>=0.7.0` accordingly.
- Connection retries (`E2B_CONNECTION_RETRIES`, default 3) use pyqwest's
transport-level retry middleware (`pyqwest.middleware.retry`), narrowed
to retry only the builtin `ConnectionError` — raised solely while
establishing the connection, before the request could have reached envd
— with exponential backoff. A retry can therefore never replay a
delivered request, for unary and streaming RPCs alike; the previous
stack's replay of unary calls whose connection dropped mid-request is
dropped deliberately, since it could re-execute a delivered call (e.g.
`SendInput`). Pinned by unit tests plus end-to-end tests driving the
generated stubs through the middleware
(`tests/test_envd_retry_transport.py`).
- For async streaming calls (`commands.run`/`connect`, PTY,
`watch_dir`), `request_timeout` bounds opening the stream — the wait
until envd confirms with a start event, matching the JS SDK's
`requestTimeoutMs` — raising `TimeoutException` and cancelling the
HTTP/2 stream when exceeded (pinned frame-level in
`tests/test_envd_stream_reset.py`). The running stream stays bounded by
the command/watch `timeout`. The sync SDK cannot interrupt its blocking
wait, so `request_timeout` is not applied to sync stream setup — both
setup and the running stream are bounded by `timeout` (unlimited when
`0`).
- The RPC logging interceptor was upstreamed to pyqwest as a logging
middleware
([curioswitch/pyqwest#192](https://github.com/curioswitch/pyqwest/pull/192));
the SDK keeps its own `LoggingInterceptor` until that merges and ships
in a release the SDK can depend on.
- `pyqwest` ships binary wheels for manylinux/musllinux (x86_64,
aarch64), macOS arm64 + x86_64 (Intel wheels landed in 0.7.0), Windows
x64, and PyPy.
- The `RST_STREAM`-on-early-close behavior is pinned by frame-level
regression tests (`tests/test_envd_stream_reset.py`): a plaintext HTTP/2
server records the frames the real generated clients (with the SDK's
codec and interceptors) send — early close via `disconnect()`, close
through the logging interceptor, and abandoning the stream must all send
`RST_STREAM(CANCEL)`; normal completion must send none (sync + async).
- `E2B_MAX_CONNECTIONS` no longer applies to sandbox RPC traffic:
reqwest's pool bounds only idle connections per host
(`E2B_KEEPALIVE_EXPIRY`, `E2B_MAX_KEEPALIVE_CONNECTIONS`), not the total
number of open connections. It still applies to the REST API and file
transfers.
- The sync sandbox modules build one RPC client each and share it across
threads — the connectrpc sync client is stateless per call over the
process-global transport (verified with a 16-thread frame-level test);
only the httpx envd API clients stay per-thread with their transports.
- Also fixes numeric env-var parsing (`E2B_KEEPALIVE_EXPIRY`,
`E2B_MAX_KEEPALIVE_CONNECTIONS`, `E2B_MAX_CONNECTIONS`,
`E2B_CONNECTION_RETRIES`): an empty-string value now falls back to the
default instead of raising `ValueError` at import time.
Migrates the Python SDK's packaging and CI from Poetry to
[uv](https://docs.astral.sh/uv/): `pyproject.toml` is converted to PEP
621 metadata using uv's native `uv_build` backend (verified to produce a
byte-equivalent wheel containing both `e2b` and `e2b_connect`),
`poetry.lock` is replaced with `uv.lock`, and the `Makefile`,
`package.json` scripts, `.tool-versions`, `CLAUDE.md`, and all six
GitHub workflows now use `uv` (`astral-sh/setup-uv` + `uv
sync`/`build`/`version`/`publish`). It also drops the now-redundant
explicit sync steps (since `uv run` auto-syncs) and removes the orphaned
`pydoc-markdown` dev dependency, whose only consumer was deleted long
ago — trimming 58 packages from the dev lockfile.
## Usage
```sh
cd packages/python-sdk
uv sync # install deps (replaces `poetry install`)
uv run pytest # run tests
uv build # build the wheel/sdist
make lint # ruff (run via `uv run`)
```
No user-facing SDK change — packaging/tooling only — so no changeset is
included; the published package contents are unchanged.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why
The Release run
[27844631141](https://github.com/e2b-dev/E2B/actions/runs/27844631141/job/82412481993)
**published all packages successfully** but then failed at the final
*"Commit new versions"* step — the version-bump commit-back to \`main\`
was rejected as non-fast-forward (another PR landed on \`main\` during
the release window).
As a result the registries are ahead of the repo:
| Package | Published | Repo (main) before this PR |
|---|---|---|
| \`e2b\` (JS) | 2.30.4 (npm) | 2.30.3 |
| \`@e2b/cli\` | 2.12.2 (npm) | 2.12.1 |
| \`e2b\` (Python) | 2.29.4 (PyPI) | 2.29.3 |
The changeset \`fix-logo-pypi-npm.md\` was also never consumed and is
still on \`main\`.
## What this PR does
Replays exactly what the failed *"Commit new versions"* step would have
committed — i.e. \`pnpm run version\` (changeset version +
\`postVersion\` poetry sync) + lockfile update:
- Bumps \`e2b\` → 2.30.4, \`@e2b/cli\` → 2.12.2, \`@e2b/python-sdk\` →
2.29.4 (matching what's already published)
- Deletes the consumed changeset \`fix-logo-pypi-npm.md\`
- Updates \`pnpm-lock.yaml\` (CLI's \`e2b\` dep → 2.30.4)
No new packages are published by merging this — it only syncs the repo
to the registries. **Do not re-run the Release workflow** for this
changeset; the versions already exist on npm/PyPI.
## Summary
Resolves all 31 open [Dependabot
alerts](https://github.com/e2b-dev/e2b/security/dependabot) across the
workspace.
- **npm** — added range-based `pnpm.overrides` to bump vulnerable
transitive deps to their patched versions: postcss, vite, lodash,
brace-expansion, picomatch (2.x + 4.x), yaml, @tootallnate/once,
smol-toml, flatted, and minimatch (3.x/5.x/9.x/10.x).
- **python-sdk** — bumped dev deps in `poetry.lock`: pytest 7.4 → 9.0.3
(with constraint update in `pyproject.toml`), pytest-asyncio 0.23 → 1.3
(required for pytest 9), python-dotenv 1.2.2, pygments 2.20.0, requests
2.33.1, black 26.3.1; removed 4 now-unused `# ty: ignore` directives
that pytest 9's stricter type signatures made obsolete.
## Test plan
- [x] \`pnpm run typecheck\` passes
- [x] \`pnpm run lint\` passes
- [x] \`pnpm run format\` clean
- [x] CLI tests (80/80) and js-sdk/python-sdk unit tests pass;
integration tests not run locally (need \`E2B_API_KEY\`)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>