Skip to content

Commit 00253c3

Browse files
authored
feat(python-sdk): migrate envd RPC to the official connectrpc client (#1558)
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](curioswitch/pyqwest#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.
1 parent 5e141a7 commit 00253c3

74 files changed

Lines changed: 6192 additions & 3555 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@e2b/python-sdk": minor
3+
---
4+
5+
Migrate the sandbox RPC layer (commands, PTY, filesystem watch) from the
6+
vendored `e2b_connect` client to the official Connect RPC client for Python
7+
([`connectrpc`](https://pypi.org/project/connectrpc/)), whose HTTP transport
8+
is `pyqwest` (Rust reqwest/hyper), and switch the envd protobuf messages from
9+
Google's `protobuf` runtime to Buf's
10+
[`protobuf-py`](https://pypi.org/project/protobuf-py/).
11+
12+
Closing a command or watch stream early now sends `RST_STREAM` to the server,
13+
so abandoned streams no longer leak on the shared HTTP/2 connection, and peer
14+
resets surface as typed errors instead of ambiguous EOFs. The REST API and
15+
file upload/download keep using `httpx`.
16+
17+
Notes:
18+
19+
- The SDK no longer depends on the `protobuf` package, removing a common
20+
source of dependency conflicts with other libraries that pin it.
21+
- The `e2b_connect` module is no longer shipped with the package. Code that
22+
imported it directly should use `connectrpc` (`ConnectError`, `Code`)
23+
instead; SDK exception types (`SandboxException`, `TimeoutException`, ...)
24+
are unchanged.
25+
- The generated `e2b.envd.*.*_pb2` modules were replaced by `protobuf-py`
26+
equivalents (`e2b.envd.process.process_pb`,
27+
`e2b.envd.filesystem.filesystem_pb`) with a different message API.
28+
- Connection retries for sandbox RPC calls (`E2B_CONNECTION_RETRIES`, default
29+
3) now retry only failures establishing the connection — before the request
30+
could have reached envd — with exponential backoff. Unary RPCs are no
31+
longer replayed when the connection drops mid-request, which could
32+
re-execute a delivered call (e.g. re-send process input); such drops
33+
surface as errors immediately, the way they always did for streaming calls.
34+
- The `proxy` option applies to sandbox RPC calls the same way it does to the
35+
REST API and file transfer requests. URL strings, `httpx.URL`, and
36+
`httpx.Proxy` values keep working (credentials in the URL or in
37+
`httpx.Proxy(auth=...)`); `httpx.Proxy` custom headers and `ssl_context`
38+
are not supported for RPC calls and raise `InvalidArgumentException`.
39+
- `CommandResult.error` (and `CommandHandle.error`) is now `None` when a
40+
command finishes without an error, matching the declared `Optional[str]`
41+
type and the JS SDK's `error?: string`. It used to be `""` on success —
42+
code comparing `result.error == ""` or treating it as always-`str` should
43+
check for `None`/falsiness instead.
44+
- For async streaming calls (`commands.run`/`connect`, PTY,
45+
`files.watch_dir`), `request_timeout` now bounds opening the stream — the
46+
wait until envd confirms with a start event, matching the JS SDK's
47+
`requestTimeoutMs` — and raises `TimeoutException` when exceeded. The
48+
running stream is bounded by the command/watch `timeout` (as before). In
49+
the sync SDK there is no way to interrupt the blocking wait, so
50+
`request_timeout` is not applied to opening the stream — both stream setup
51+
and the running stream are bounded by `timeout` (unlimited when `0`).
52+
- `E2B_MAX_CONNECTIONS` no longer applies to sandbox RPC traffic: the new
53+
transport bounds only idle connections per host (`E2B_KEEPALIVE_EXPIRY`,
54+
`E2B_MAX_KEEPALIVE_CONNECTIONS`), not the total number of open
55+
connections. It still applies to the REST API and file transfers.

.github/workflows/codegen_image_cache.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ on:
88
branches: [main]
99
paths:
1010
- 'codegen.Dockerfile'
11-
- 'packages/connect-python/**'
1211
- '.github/workflows/codegen_image_cache.yml'
1312
workflow_dispatch:
1413

.github/workflows/sdk_tests.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ jobs:
4646
- *shared
4747
- '.github/workflows/python_sdk_tests.yml'
4848
- 'packages/python-sdk/**/!(*.md)'
49-
- 'packages/connect-python/**/!(*.md)'
5049
cli:
5150
- *shared
5251
- '.github/workflows/cli_tests.yml'

codegen.Dockerfile

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,23 @@ RUN go install github.com/bufbuild/buf/cmd/buf@v1.50.1 && \
55
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28.1 && \
66
go install connectrpc.com/connect/cmd/protoc-gen-connect-go@v1.18.1
77

8-
# Install our custom protoc plugin, connect-python
9-
COPY ./packages/connect-python /packages/connect-python
10-
RUN cd /packages/connect-python && make bin/protoc-gen-connect-python
11-
128

139
FROM python:3.10
1410

1511
# Set working directory
1612
WORKDIR /workspace
1713

18-
ENV PROTOC_VERSION=26.1
19-
RUN ARCH=$(uname -m) && \
20-
case "$ARCH" in \
21-
x86_64) PROTOC_ARCH="x86_64" ;; \
22-
arm64|aarch64) PROTOC_ARCH="aarch_64" ;; \
23-
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
24-
esac && \
25-
curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip && \
26-
unzip -o protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip -d /usr/local && \
27-
rm protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip
28-
2914
# Copy installed Go deps from previous build step
3015
COPY --from=0 /go /go
3116

32-
# Add Go binary to PATH
17+
# Add Go binary to PATH
3318
ENV PATH="/go/bin:${PATH}"
3419

3520
# Install Python deps (e2b-openapi-python-client is patched version to fix issue with explode)
3621
# https://github.com/openapi-generators/openapi-python-client/pull/1296
37-
RUN pip install black==26.3.1 pyyaml==6.0.2 e2b-openapi-python-client==0.26.2 datamodel-code-generator==0.34.0
22+
# protoc-gen-py generates the Python envd protobuf-py messages and
23+
# protoc-gen-connectrpc the Connect stubs.
24+
RUN pip install black==26.3.1 pyyaml==6.0.2 e2b-openapi-python-client==0.26.2 datamodel-code-generator==0.34.0 protoc-gen-connectrpc==0.11.1 protoc-gen-py==0.1.1
3825

3926
# Install Node.js (pinned to match .tool-versions)
4027
ENV NODE_VERSION=22.18.0

packages/connect-python/.gitignore

Lines changed: 0 additions & 1 deletion
This file was deleted.

packages/connect-python/LICENSE

Lines changed: 0 additions & 201 deletions
This file was deleted.

packages/connect-python/Makefile

Lines changed: 0 additions & 29 deletions
This file was deleted.

packages/connect-python/README.md

Lines changed: 0 additions & 7 deletions
This file was deleted.

0 commit comments

Comments
 (0)