Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/python-http1-transports.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/python-sdk/e2b/api/client_async/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
2 changes: 1 addition & 1 deletion packages/python-sdk/e2b/api/client_sync/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
7 changes: 7 additions & 0 deletions packages/python-sdk/e2b/connection_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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,
)
)

Expand Down
4 changes: 3 additions & 1 deletion packages/python-sdk/e2b/sandbox_async/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/python-sdk/e2b/sandbox_sync/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Original file line number Diff line number Diff line change
Expand Up @@ -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}
54 changes: 45 additions & 9 deletions packages/python-sdk/tests/test_api_client_transport.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
from concurrent.futures import ThreadPoolExecutor
from typing import Any

import pytest

Expand Down Expand Up @@ -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(
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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()