refactor(python-sdk): share one envd HTTP client across the sync sandbox modules (#1655)

The sync flavor built four envd HTTP clients per sandbox — `Filesystem`,
`Commands` and `Pty` each constructed their own — while the async flavor
built one in `Sandbox.__init__` and threaded it down; this builds it
once on the sync side too and passes it into the three modules. No
functional change: `get_envd_transport` already caches the pyqwest
transport per `(proxy, for_streaming)` process-wide, so those four
clients already shared one connection pool — the cost was a few
`httpx.Client` wrappers per sandbox, plus a sync/async divergence that
CLAUDE.md and TASTE.md both ask us to avoid. It also clears the last
cosmetic differences between the two flavors: async `Commands`/`Pty`
swap their `_check_health` lambda closure for the sync side's attribute
+ method, sync `Commands`/`Pty` drop a write-only `_envd_api_url`, and
async `Filesystem` builds its RPC client first to match sync — the three
constructor pairs now differ only in the sync/async client and RPC class
names. All constructors touched are internal, so there is no public API
change and nothing to show as a usage example.

Verified with 336 unit tests, 92 sync and 89 async integration tests
against prod (`commands`, `pty`, `files`), plus `make lint` and `make
typecheck`.

Closes
[SDK-322](https://linear.app/e2b/issue/SDK-322/python-sdk-share-one-envd-http-client-across-the-sync-sandbox-modules)

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mish Ushakov
2026-08-10 20:40:42 +02:00
committed by GitHub
parent cfd4bedd90
commit 11912ffa04
9 changed files with 59 additions and 38 deletions
@@ -0,0 +1,11 @@
---
"@e2b/python-sdk": patch
---
Build the envd HTTP API client once per sync `Sandbox` and share it across the
filesystem, commands, and PTY modules, which now receive it instead of each
constructing their own — matching `AsyncSandbox`. No behavior change: the
pyqwest transport underneath is already cached process-wide per
`(proxy, for_streaming)`, so the separate clients shared one connection pool
either way. `Filesystem` still builds the streaming sibling client whose
transport carries the idle read timeout, in both flavors.
@@ -43,12 +43,15 @@ class Commands:
) -> None:
self._connection_config = connection_config
self._envd_version = envd_version
self._check_health = lambda: acheck_sandbox_health(envd_api)
self._rpc = create_rpc_client(
process_connect.ProcessClient,
envd_api_url,
connection_config,
)
self._envd_api = envd_api
async def _check_health(self) -> Optional[bool]:
return await acheck_sandbox_health(self._envd_api)
async def list(
self,
@@ -44,12 +44,15 @@ class Pty:
) -> None:
self._connection_config = connection_config
self._envd_version = envd_version
self._check_health = lambda: acheck_sandbox_health(envd_api)
self._rpc = create_rpc_client(
process_connect.ProcessClient,
envd_api_url,
connection_config,
)
self._envd_api = envd_api
async def _check_health(self) -> Optional[bool]:
return await acheck_sandbox_health(self._envd_api)
async def kill(
self,
@@ -94,6 +94,11 @@ class Filesystem:
self._envd_api_url = envd_api_url
self._envd_version = envd_version
self._connection_config = connection_config
self._rpc = create_rpc_client(
filesystem_connect.FilesystemClient,
envd_api_url,
connection_config,
)
self._envd_api = envd_api
# Streamed downloads default to a sibling client whose transport
# carries the idle read timeout (see `get_envd_transport`).
@@ -101,12 +106,6 @@ class Filesystem:
connection_config, envd_api_url, for_streaming=True
)
self._rpc = create_rpc_client(
filesystem_connect.FilesystemClient,
envd_api_url,
connection_config,
)
@overload
async def read(
self,
@@ -1,9 +1,9 @@
from typing import Callable, Dict, List, Literal, Optional, Union, overload
import httpx
from connectrpc.code import Code
from connectrpc.errors import ConnectError
from packaging.version import Version
from e2b.api.client_sync import get_envd_api
from e2b.connection_config import (
ConnectionConfig,
Username,
@@ -38,8 +38,8 @@ class Commands:
envd_api_url: str,
connection_config: ConnectionConfig,
envd_version: Version,
envd_api: httpx.Client,
) -> None:
self._envd_api_url = envd_api_url
self._connection_config = connection_config
self._envd_version = envd_version
self._rpc = create_rpc_client(
@@ -47,9 +47,7 @@ class Commands:
envd_api_url,
connection_config,
)
# Like the RPC client, the pyqwest transport underneath is
# thread-safe, so one client serves all threads.
self._envd_api = get_envd_api(connection_config, envd_api_url)
self._envd_api = envd_api
def _check_health(self) -> Optional[bool]:
return check_sandbox_health(self._envd_api)
@@ -1,9 +1,10 @@
from typing import Dict, Optional
import httpx
from connectrpc.code import Code
from connectrpc.errors import ConnectError
from packaging.version import Version
from e2b.api.client_sync import get_envd_api
from protobuf import Oneof
from e2b.envd.process import process_connect, process_pb
@@ -35,8 +36,8 @@ class Pty:
envd_api_url: str,
connection_config: ConnectionConfig,
envd_version: Version,
envd_api: httpx.Client,
) -> None:
self._envd_api_url = envd_api_url
self._connection_config = connection_config
self._envd_version = envd_version
self._rpc = create_rpc_client(
@@ -44,9 +45,7 @@ class Pty:
envd_api_url,
connection_config,
)
# Like the RPC client, the pyqwest transport underneath is
# thread-safe, so one client serves all threads.
self._envd_api = get_envd_api(connection_config, envd_api_url)
self._envd_api = envd_api
def _check_health(self) -> Optional[bool]:
return check_sandbox_health(self._envd_api)
@@ -83,6 +83,7 @@ class Filesystem:
envd_api_url: str,
envd_version: Version,
connection_config: ConnectionConfig,
envd_api: httpx.Client,
) -> None:
self._envd_api_url = envd_api_url
self._envd_version = envd_version
@@ -92,10 +93,11 @@ class Filesystem:
envd_api_url,
connection_config,
)
# Like the RPC client, the pyqwest transports underneath are
# thread-safe, so one client (and its streaming sibling, whose
# transport carries the idle read timeout) serves all threads.
self._envd_api = get_envd_api(connection_config, envd_api_url)
self._envd_api = envd_api
# Streamed downloads default to a sibling client whose transport
# carries the idle read timeout (see `get_envd_transport`). Like the
# RPC client, the pyqwest transports underneath are thread-safe, so
# one client serves all threads.
self._envd_api_streaming = get_envd_api(
connection_config, envd_api_url, for_streaming=True
)
+5 -4
View File
@@ -10,6 +10,7 @@ from packaging.version import Version
from typing_extensions import Self, Unpack
from e2b.api.client.types import Unset
from e2b.api.client_sync import get_envd_api
from e2b.connection_config import ApiParams, ConnectionConfig
from e2b.envd.api import ENVD_API_HEALTH_ROUTE, handle_envd_api_exception
from e2b.envd.versions import ENVD_DEBUG_FALLBACK
@@ -102,27 +103,27 @@ class Sandbox(SandboxApi):
"""
super().__init__(**opts)
self._envd_api = get_envd_api(self.connection_config, self.envd_api_url)
self._filesystem = Filesystem(
self.envd_api_url,
self._envd_version,
self.connection_config,
self._envd_api,
)
self._commands = Commands(
self.envd_api_url,
self.connection_config,
self._envd_version,
self._envd_api,
)
self._pty = Pty(
self.envd_api_url,
self.connection_config,
self._envd_version,
self._envd_api,
)
self._git = Git(self._commands)
@property
def _envd_api(self) -> httpx.Client:
return self._filesystem._envd_api
def is_running(self, request_timeout: Optional[float] = None) -> bool:
"""
Check if the sandbox is running.
@@ -1,10 +1,9 @@
"""Client lifecycle in the sync sandbox modules: the httpx `envd_api`
clients and the connectrpc RPC clients are both cheap stateless wrappers
over shared, process-global pyqwest transports — built once per module and
shared across threads."""
over shared, process-global pyqwest transports — built once per sandbox and
shared across the modules and across threads."""
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
from unittest.mock import Mock, sentinel
import httpx
@@ -25,16 +24,23 @@ def run_in_worker_thread(fn):
return executor.submit(fn).result()
def test_sync_sandbox_envd_api_delegates_to_filesystem(monkeypatch, test_api_key):
def test_sync_sandbox_shares_one_envd_api_across_modules(monkeypatch, test_api_key):
config = ConnectionConfig(api_key=test_api_key)
main_api = Mock(spec=httpx.Client)
filesystem = SimpleNamespace(_envd_api=main_api)
received = {}
def record(name):
def factory(*args, **kwargs):
received[name] = args[-1]
return object()
return factory
monkeypatch.setattr(
sandbox_sync_main, "Filesystem", lambda *args, **kwargs: filesystem
sandbox_sync_main, "get_envd_api", lambda *_args, **_kwargs: main_api
)
monkeypatch.setattr(sandbox_sync_main, "Commands", lambda *args, **kwargs: object())
monkeypatch.setattr(sandbox_sync_main, "Pty", lambda *args, **kwargs: object())
for module in ("Filesystem", "Commands", "Pty"):
monkeypatch.setattr(sandbox_sync_main, module, record(module))
monkeypatch.setattr(sandbox_sync_main, "Git", lambda *args, **kwargs: object())
sandbox = sandbox_sync_main.Sandbox(
@@ -47,7 +53,7 @@ def test_sync_sandbox_envd_api_delegates_to_filesystem(monkeypatch, test_api_key
)
assert sandbox._envd_api is main_api
assert sandbox._envd_api is sandbox.files._envd_api
assert received == {"Filesystem": main_api, "Commands": main_api, "Pty": main_api}
assert not hasattr(sandbox, "_transport")
@@ -60,9 +66,7 @@ def test_sync_filesystem_clients_are_shared_across_threads(monkeypatch, test_api
monkeypatch.setattr(
filesystem_sync,
"get_envd_api",
lambda *_args, **kwargs: streaming_api
if kwargs.get("for_streaming")
else shared_api,
lambda *_args, **_kwargs: streaming_api,
)
monkeypatch.setattr(
filesystem_sync,
@@ -74,6 +78,7 @@ def test_sync_filesystem_clients_are_shared_across_threads(monkeypatch, test_api
ENVD_API_URL,
ENVD_VERSION,
config,
shared_api,
)
assert fs._envd_api is shared_api