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:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user