From 11b2f2dbe6a184e83d520e1c016be46feb6816fe Mon Sep 17 00:00:00 2001 From: sarmientoF Date: Tue, 9 Jun 2026 15:47:05 +0900 Subject: [PATCH] feat: add python sdk http2 transport option --- .changeset/python-http1-transports.md | 5 ++ .../e2b/api/client_async/__init__.py | 2 +- .../e2b/api/client_sync/__init__.py | 2 +- packages/python-sdk/e2b/connection_config.py | 7 +++ packages/python-sdk/e2b/sandbox_async/main.py | 4 +- packages/python-sdk/e2b/sandbox_sync/main.py | 4 +- .../sandbox_async/test_config_propagation.py | 28 ++++++++++ .../sandbox_sync/test_config_propagation.py | 26 +++++++++ .../tests/test_api_client_transport.py | 54 +++++++++++++++---- 9 files changed, 119 insertions(+), 13 deletions(-) create mode 100644 .changeset/python-http1-transports.md diff --git a/.changeset/python-http1-transports.md b/.changeset/python-http1-transports.md new file mode 100644 index 0000000000..32da3f0a05 --- /dev/null +++ b/.changeset/python-http1-transports.md @@ -0,0 +1,5 @@ +--- +"@e2b/python-sdk": patch +--- + +Add a Python SDK `http2` connection option so high fan-out workloads can opt out of HTTP/2. diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index 85300f5902..8c42fda07c 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -13,7 +13,7 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient: return AsyncApiClient( config, - transport=get_transport(config), + transport=get_transport(config, http2=config.http2), **kwargs, ) diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index e44c43707c..7b49aed66a 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -13,7 +13,7 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient: return ApiClient( config, - transport=get_transport(config), + transport=get_transport(config, http2=config.http2), **kwargs, ) diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index e24cedad27..3ddcd4d44b 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -48,6 +48,9 @@ class ApiParams(TypedDict, total=False): sandbox_url: Optional[str] """URL to connect to sandbox, defaults to `E2B_SANDBOX_URL` environment variable.""" + http2: Optional[bool] + """Whether to use HTTP/2 for API and sandbox requests, defaults to `True`.""" + class ConnectionConfig: """ @@ -93,6 +96,7 @@ def __init__( api_headers: Optional[Dict[str, str]] = None, extra_sandbox_headers: Optional[Dict[str, str]] = None, proxy: Optional[ProxyTypes] = None, + http2: Optional[bool] = None, ): self.domain = domain or ConnectionConfig._domain() self.debug = debug or ConnectionConfig._debug() @@ -103,6 +107,7 @@ def __init__( self.__extra_sandbox_headers = extra_sandbox_headers or {} self.proxy = proxy + self.http2 = True if http2 is None else http2 self.request_timeout = ConnectionConfig._get_request_timeout( REQUEST_TIMEOUT, @@ -195,6 +200,7 @@ def get_api_params( domain = opts.get("domain") debug = opts.get("debug") proxy = opts.get("proxy") + http2 = opts.get("http2") req_headers = self.headers.copy() if headers is not None: @@ -211,6 +217,7 @@ def get_api_params( request_timeout=self.get_request_timeout(request_timeout), headers=req_headers, proxy=proxy if proxy is not None else self.proxy, + http2=http2 if http2 is not None else self.http2, ) ) diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 1c8ce0786a..128e200214 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -102,7 +102,9 @@ def __init__( """ super().__init__(**opts) - self._transport = get_transport(self.connection_config) + self._transport = get_transport( + self.connection_config, http2=self.connection_config.http2 + ) self._envd_api = httpx.AsyncClient( base_url=self.connection_config.get_sandbox_url( self.sandbox_id, self.sandbox_domain diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index e21abe1129..2eb1519495 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -101,7 +101,9 @@ def __init__(self, **opts: Unpack[SandboxOpts]): """ super().__init__(**opts) - self._transport = get_transport(self.connection_config) + self._transport = get_transport( + self.connection_config, http2=self.connection_config.http2 + ) self._envd_api = httpx.Client( base_url=self.envd_api_url, diff --git a/packages/python-sdk/tests/async/sandbox_async/test_config_propagation.py b/packages/python-sdk/tests/async/sandbox_async/test_config_propagation.py index c69f9f6111..02e4162843 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_config_propagation.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_config_propagation.py @@ -114,3 +114,31 @@ async def test_connect_sets_stable_host_routing_headers(monkeypatch, test_api_ke ConnectionConfig.envd_port ) assert sandbox.connection_config.sandbox_headers["X-Access-Token"] == "tok" + + +@pytest.mark.skip_debug() +async def test_create_passes_http2_to_envd_transport(monkeypatch, test_api_key): + dummy_transport = SimpleNamespace(pool=object()) + captured = {} + + def get_transport(config, **kwargs): + captured["config_http2"] = config.http2 + captured["http2"] = kwargs["http2"] + return dummy_transport + + monkeypatch.setattr(sandbox_async_main, "get_transport", get_transport) + monkeypatch.setattr( + sandbox_async_main.httpx, "AsyncClient", lambda *args, **kwargs: object() + ) + monkeypatch.setattr( + sandbox_async_main, "Filesystem", lambda *args, **kwargs: object() + ) + monkeypatch.setattr( + sandbox_async_main, "Commands", lambda *args, **kwargs: object() + ) + monkeypatch.setattr(sandbox_async_main, "Pty", lambda *args, **kwargs: object()) + monkeypatch.setattr(sandbox_async_main, "Git", lambda *args, **kwargs: object()) + + await AsyncSandbox.create(debug=True, api_key=test_api_key, http2=False) + + assert captured == {"config_http2": False, "http2": False} diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_config_propagation.py b/packages/python-sdk/tests/sync/sandbox_sync/test_config_propagation.py index b78c282d8d..08c32cc9ce 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_config_propagation.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_config_propagation.py @@ -108,3 +108,29 @@ def test_connect_sets_stable_host_routing_headers(monkeypatch, test_api_key): ConnectionConfig.envd_port ) assert sandbox.connection_config.sandbox_headers["X-Access-Token"] == "tok" + + +@pytest.mark.skip_debug() +def test_create_passes_http2_to_envd_transport(monkeypatch, test_api_key): + dummy_transport = SimpleNamespace(pool=object()) + captured = {} + + def get_transport(config, **kwargs): + captured["config_http2"] = config.http2 + captured["http2"] = kwargs["http2"] + return dummy_transport + + monkeypatch.setattr(sandbox_sync_main, "get_transport", get_transport) + monkeypatch.setattr( + sandbox_sync_main.httpx, "Client", lambda *args, **kwargs: object() + ) + monkeypatch.setattr( + sandbox_sync_main, "Filesystem", lambda *args, **kwargs: object() + ) + monkeypatch.setattr(sandbox_sync_main, "Commands", lambda *args, **kwargs: object()) + monkeypatch.setattr(sandbox_sync_main, "Pty", lambda *args, **kwargs: object()) + monkeypatch.setattr(sandbox_sync_main, "Git", lambda *args, **kwargs: object()) + + Sandbox.create(debug=True, api_key=test_api_key, http2=False) + + assert captured == {"config_http2": False, "http2": False} diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index 9b96491f58..ddf85afeac 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -1,5 +1,6 @@ import asyncio from concurrent.futures import ThreadPoolExecutor +from typing import Any import pytest @@ -27,6 +28,10 @@ def run_in_worker_thread(fn): return executor.submit(fn).result() +def transport_uses_http2(transport: Any) -> bool: + return bool(getattr(transport._pool, "_http2")) + + def test_sync_api_client_proxy_uses_explicit_transport(test_api_key): reset_sync_api_transports() config = ConnectionConfig( @@ -39,7 +44,7 @@ def test_sync_api_client_proxy_uses_explicit_transport(test_api_key): try: assert "proxy" not in api_client._httpx_args - assert httpx_client._transport is get_sync_transport(config) + assert httpx_client._transport is get_sync_transport(config, http2=True) assert httpx_client._mounts == {} finally: httpx_client.close() @@ -54,9 +59,9 @@ def test_sync_get_transport_http2_opt_out_returns_distinct_instance(test_api_key http2_transport = get_sync_transport(config) http1_transport = get_sync_transport(config, http2=False) + assert transport_uses_http2(http2_transport) is True + assert transport_uses_http2(http1_transport) is False assert http2_transport is not http1_transport - assert http2_transport._pool._http2 is True - assert http1_transport._pool._http2 is False # Subsequent calls with the same http2 flag return the cached # instance. assert get_sync_transport(config) is http2_transport @@ -65,6 +70,21 @@ def test_sync_get_transport_http2_opt_out_returns_distinct_instance(test_api_key reset_sync_api_transports() +def test_sync_api_client_respects_connection_config_http2_opt_out(test_api_key): + reset_sync_api_transports() + config = ConnectionConfig(api_key=test_api_key, http2=False) + + api_client = get_sync_api_client(config) + httpx_client = api_client.get_httpx_client() + + try: + assert httpx_client._transport is get_sync_transport(config, http2=False) + assert transport_uses_http2(httpx_client._transport) is False + finally: + httpx_client.close() + reset_sync_api_transports() + + def test_sync_envd_transport_uses_separate_cache(test_api_key): reset_sync_api_transports() reset_sync_envd_transports() @@ -77,7 +97,7 @@ def test_sync_envd_transport_uses_separate_cache(test_api_key): assert api_transport is not envd_transport assert get_sync_transport(config) is api_transport assert get_sync_envd_transport(config) is envd_transport - assert envd_transport._pool._http2 is True + assert transport_uses_http2(envd_transport) is True finally: reset_sync_api_transports() reset_sync_envd_transports() @@ -112,8 +132,8 @@ def test_sync_envd_transport_cache_is_thread_local(test_api_key): assert main_transport is get_sync_envd_transport(config) assert thread_transport is not main_transport - assert main_transport._pool._http2 is True - assert thread_transport._pool._http2 is True + assert transport_uses_http2(main_transport) is True + assert transport_uses_http2(thread_transport) is True finally: reset_sync_envd_transports() @@ -152,9 +172,9 @@ async def test_async_get_transport_http2_opt_out_returns_distinct_instance( http2_transport = get_async_transport(config) http1_transport = get_async_transport(config, http2=False) + assert transport_uses_http2(http2_transport) is True + assert transport_uses_http2(http1_transport) is False assert http2_transport is not http1_transport - assert http2_transport._pool._http2 is True - assert http1_transport._pool._http2 is False # Subsequent calls with the same http2 flag return the cached # instance. assert get_async_transport(config) is http2_transport @@ -163,6 +183,22 @@ async def test_async_get_transport_http2_opt_out_returns_distinct_instance( AsyncTransportWithLogger._instances.clear() +@pytest.mark.asyncio +async def test_async_api_client_respects_connection_config_http2_opt_out(test_api_key): + AsyncTransportWithLogger._instances.clear() + config = ConnectionConfig(api_key=test_api_key, http2=False) + + api_client = get_async_api_client(config) + httpx_client = api_client.get_async_httpx_client() + + try: + assert httpx_client._transport is get_async_transport(config, http2=False) + assert transport_uses_http2(httpx_client._transport) is False + finally: + await httpx_client.aclose() + AsyncTransportWithLogger._instances.clear() + + @pytest.mark.asyncio async def test_async_envd_transport_uses_separate_cache(test_api_key): AsyncTransportWithLogger._instances.clear() @@ -176,7 +212,7 @@ async def test_async_envd_transport_uses_separate_cache(test_api_key): assert api_transport is not envd_transport assert get_async_transport(config) is api_transport assert get_async_envd_transport(config) is envd_transport - assert envd_transport._pool._http2 is True + assert transport_uses_http2(envd_transport) is True finally: AsyncTransportWithLogger._instances.clear() AsyncEnvdTransportWithLogger._instances.clear()