From 88ba9f83f7dc7970d86f8f898949ab96909de63f Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:34:26 +0200 Subject: [PATCH] fix(python-sdk): restore the http2 parameter on the transport factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pyqwest migration in 2.38.0 dropped `http2` from `get_transport` and `get_envd_transport`, so `get_transport(config, http2=False)` — which e2b-code-interpreter uses for its Jupyter requests — now raises `TypeError`. pyqwest supports the capability (`http_version`), it just was not threaded through. `http2=False` again returns a transport pinned to HTTP/1.1, on its own pool: the HTTP version is back in both cache keys, so a caller can never be handed a transport of the other version. The default is unchanged — `http_version=None` leaves TLS connections to ALPN (HTTP/2 against the E2B API) and uses HTTP/1 for plaintext. Co-Authored-By: Claude Opus 5 (1M context) --- .../python-sdk-restore-http2-parameter.md | 5 + .../e2b/api/client_async/__init__.py | 61 +++++-- .../e2b/api/client_sync/__init__.py | 54 +++++-- .../tests/test_api_client_transport.py | 153 ++++++++++++++++++ 4 files changed, 240 insertions(+), 33 deletions(-) create mode 100644 .changeset/python-sdk-restore-http2-parameter.md diff --git a/.changeset/python-sdk-restore-http2-parameter.md b/.changeset/python-sdk-restore-http2-parameter.md new file mode 100644 index 0000000000..e37c7ca37f --- /dev/null +++ b/.changeset/python-sdk-restore-http2-parameter.md @@ -0,0 +1,5 @@ +--- +'@e2b/python-sdk': patch +--- + +Restore the `http2` parameter on `get_transport` and `get_envd_transport`, which the pyqwest migration dropped in 2.38.0. `http2=False` again returns a transport pinned to HTTP/1.1, on its own connection pool. diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index d40ca257e6..e8992e581d 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -3,7 +3,7 @@ import httpx -from pyqwest import HTTPTransport, Request, Response +from pyqwest import HTTPTransport, HTTPVersion, Request, Response from pyqwest.httpx import AsyncPyqwestTransport from pyqwest.middleware.retry import RetryTransport @@ -40,7 +40,9 @@ def should_retry_response( def retrying_http_transport( - proxy: Optional[ProxyConfig], read_timeout: Optional[float] = None + proxy: Optional[ProxyConfig], + read_timeout: Optional[float] = None, + http2: bool = True, ) -> ConnectionRetryTransport: """A fresh pyqwest transport (= its own connection pool) with the SDK's shared tuning — system CA certs (without which TLS through an @@ -51,6 +53,9 @@ def retrying_http_transport( ``read_timeout`` bounds every read on the transport's connections; see :func:`get_envd_transport` for when that is (and isn't) appropriate. + ``http2=False`` pins the transport to HTTP/1.1; see :func:`get_transport` + for when that matters. + Requests are logged by pyqwest itself on the ``pyqwest.access`` and ``pyqwest`` loggers at ``DEBUG`` (off unless enabled) — the transport-level diagnostics httpcore used to provide. The SDK's own ``logger`` option is @@ -62,6 +67,10 @@ def retrying_http_transport( pool_idle_timeout=pool_idle_timeout, pool_max_idle_per_host=pool_max_idle_per_host, read_timeout=read_timeout, + # `None` leaves the version to ALPN on TLS connections (HTTP/2 + # against the E2B API) and uses HTTP/1 for plaintext, like the + # http2-enabled httpx transport this replaced. + http_version=None if http2 else HTTPVersion.HTTP1, # Redirects belong to the httpx client above (which the generated # clients leave off), not to reqwest. follow_redirects=False, @@ -71,33 +80,47 @@ def retrying_http_transport( _transport_lock = threading.Lock() -# One transport (= one connection pool) per proxy; None is the direct pool. -# pyqwest's I/O runs on its own Rust runtime, so unlike the httpx transports -# they replaced, the transports are not bound to an event loop and the -# caches are process-global rather than per-loop. -_transports: Dict[Optional[ProxyConfig], AsyncPyqwestTransport] = {} +# One transport (= one connection pool) per (proxy, http2) pair; a None proxy +# is the direct pool. pyqwest's I/O runs on its own Rust runtime, so unlike +# the httpx transports they replaced, the transports are not bound to an event +# loop and the caches are process-global rather than per-loop. +_transports: Dict[Tuple[Optional[ProxyConfig], bool], AsyncPyqwestTransport] = {} -def get_transport(config: ConnectionConfig) -> AsyncPyqwestTransport: +def get_transport( + config: ConnectionConfig, http2: bool = True +) -> AsyncPyqwestTransport: """The shared pyqwest-backed httpx transport for REST API calls. For TLS connections ALPN negotiates the HTTP version (HTTP/2 against the E2B - API), like the http2-enabled httpx transport this replaced.""" + API), like the http2-enabled httpx transport this replaced. + + ``http2=False`` returns a separate transport (its own pool) pinned to + HTTP/1.1. That matters for a server that reacts to a client going away: + HTTP/2 multiplexes requests over one connection, so abandoning a request + only resets its stream and the server may never notice, while HTTP/1.1's + one-connection-per-request closes the connection and the server observes + the disconnect.""" proxy = proxy_to_config(config.proxy) + key = (proxy, http2) with _transport_lock: - transport = _transports.get(proxy) + transport = _transports.get(key) if transport is None: - transport = AsyncPyqwestTransport(retrying_http_transport(proxy)) - _transports[proxy] = transport + transport = AsyncPyqwestTransport( + retrying_http_transport(proxy, http2=http2) + ) + _transports[key] = transport return transport -# One transport per (proxy, streaming) pair, separate from the REST API -# pools — envd traffic goes to per-sandbox hosts. -_envd_transports: Dict[Tuple[Optional[ProxyConfig], bool], AsyncPyqwestTransport] = {} +# One transport per (proxy, http2, streaming) triple, separate from the REST +# API pools — envd traffic goes to per-sandbox hosts. +_envd_transports: Dict[ + Tuple[Optional[ProxyConfig], bool, bool], AsyncPyqwestTransport +] = {} def get_envd_transport( - config: ConnectionConfig, *, for_streaming: bool = False + config: ConnectionConfig, http2: bool = True, *, for_streaming: bool = False ) -> AsyncPyqwestTransport: """The shared pyqwest-backed httpx transports for the envd HTTP API (file transfers, health checks). @@ -111,9 +134,12 @@ def get_envd_transport( the response head, so on the regular transport it would cut off uploads and slow unary responses longer than the idle bound (those stay bounded by their whole-request deadlines instead). + + ``http2=False`` pins the transport to HTTP/1.1 — see + :func:`get_transport`. """ proxy = proxy_to_config(config.proxy) - key = (proxy, for_streaming) + key = (proxy, http2, for_streaming) with _transport_lock: transport = _envd_transports.get(key) if transport is None: @@ -121,6 +147,7 @@ def get_envd_transport( retrying_http_transport( proxy, read_timeout=READ_TIMEOUT if for_streaming else None, + http2=http2, ) ) _envd_transports[key] = transport diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index d7331d8cc4..698b7cae12 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -3,7 +3,7 @@ import httpx import threading -from pyqwest import SyncHTTPTransport, SyncRequest, SyncResponse +from pyqwest import HTTPVersion, SyncHTTPTransport, SyncRequest, SyncResponse from pyqwest.httpx import PyqwestTransport from pyqwest.middleware.retry import SyncRetryTransport @@ -40,7 +40,9 @@ def should_retry_response( def retrying_http_transport( - proxy: Optional[ProxyConfig], read_timeout: Optional[float] = None + proxy: Optional[ProxyConfig], + read_timeout: Optional[float] = None, + http2: bool = True, ) -> ConnectionRetryTransport: """A fresh pyqwest transport (= its own connection pool) with the SDK's shared tuning — system CA certs (without which TLS through an @@ -51,6 +53,9 @@ def retrying_http_transport( ``read_timeout`` bounds every read on the transport's connections; see :func:`get_envd_transport` for when that is (and isn't) appropriate. + ``http2=False`` pins the transport to HTTP/1.1; see :func:`get_transport` + for when that matters. + Requests are logged by pyqwest itself on the ``pyqwest.access`` and ``pyqwest`` loggers at ``DEBUG`` (off unless enabled) — the transport-level diagnostics httpcore used to provide. The SDK's own ``logger`` option is @@ -62,6 +67,10 @@ def retrying_http_transport( pool_idle_timeout=pool_idle_timeout, pool_max_idle_per_host=pool_max_idle_per_host, read_timeout=read_timeout, + # `None` leaves the version to ALPN on TLS connections (HTTP/2 + # against the E2B API) and uses HTTP/1 for plaintext, like the + # http2-enabled httpx transport this replaced. + http_version=None if http2 else HTTPVersion.HTTP1, # Redirects belong to the httpx client above (which the generated # clients leave off), not to reqwest. follow_redirects=False, @@ -71,32 +80,41 @@ def retrying_http_transport( _transport_lock = threading.Lock() -# One transport (= one connection pool) per proxy; None is the direct pool. -# pyqwest transports are thread-safe, so unlike the httpx transports they -# replaced, the caches are process-global rather than per-thread. -_transports: Dict[Optional[ProxyConfig], PyqwestTransport] = {} +# One transport (= one connection pool) per (proxy, http2) pair; a None proxy +# is the direct pool. pyqwest transports are thread-safe, so unlike the httpx +# transports they replaced, the caches are process-global rather than +# per-thread. +_transports: Dict[Tuple[Optional[ProxyConfig], bool], PyqwestTransport] = {} -def get_transport(config: ConnectionConfig) -> PyqwestTransport: +def get_transport(config: ConnectionConfig, http2: bool = True) -> PyqwestTransport: """The shared pyqwest-backed httpx transport for REST API calls. For TLS connections ALPN negotiates the HTTP version (HTTP/2 against the E2B - API), like the http2-enabled httpx transport this replaced.""" + API), like the http2-enabled httpx transport this replaced. + + ``http2=False`` returns a separate transport (its own pool) pinned to + HTTP/1.1. That matters for a server that reacts to a client going away: + HTTP/2 multiplexes requests over one connection, so abandoning a request + only resets its stream and the server may never notice, while HTTP/1.1's + one-connection-per-request closes the connection and the server observes + the disconnect.""" proxy = proxy_to_config(config.proxy) + key = (proxy, http2) with _transport_lock: - transport = _transports.get(proxy) + transport = _transports.get(key) if transport is None: - transport = PyqwestTransport(retrying_http_transport(proxy)) - _transports[proxy] = transport + transport = PyqwestTransport(retrying_http_transport(proxy, http2=http2)) + _transports[key] = transport return transport -# One transport per (proxy, streaming) pair, separate from the REST API -# pools — envd traffic goes to per-sandbox hosts. -_envd_transports: Dict[Tuple[Optional[ProxyConfig], bool], PyqwestTransport] = {} +# One transport per (proxy, http2, streaming) triple, separate from the REST +# API pools — envd traffic goes to per-sandbox hosts. +_envd_transports: Dict[Tuple[Optional[ProxyConfig], bool, bool], PyqwestTransport] = {} def get_envd_transport( - config: ConnectionConfig, *, for_streaming: bool = False + config: ConnectionConfig, http2: bool = True, *, for_streaming: bool = False ) -> PyqwestTransport: """The shared pyqwest-backed httpx transports for the envd HTTP API (file transfers, health checks). @@ -111,9 +129,12 @@ def get_envd_transport( head, so on the regular transport it would cut off uploads and slow unary responses longer than the idle bound (those stay bounded by their whole-request deadlines instead). + + ``http2=False`` pins the transport to HTTP/1.1 — see + :func:`get_transport`. """ proxy = proxy_to_config(config.proxy) - key = (proxy, for_streaming) + key = (proxy, http2, for_streaming) with _transport_lock: transport = _envd_transports.get(key) if transport is None: @@ -121,6 +142,7 @@ def get_envd_transport( retrying_http_transport( proxy, read_timeout=READ_TIMEOUT if for_streaming else None, + http2=http2, ) ) _envd_transports[key] = transport diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index c37d034642..1b5f0f3c26 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -9,6 +9,7 @@ import httpx import pytest +from pyqwest import HTTPVersion from pyqwest.httpx import AsyncPyqwestTransport, PyqwestTransport import e2b.api.client_async as client_async @@ -86,6 +87,68 @@ def test_sync_get_transport_keyed_by_proxy(test_api_key): reset_sync_api_transports() +def test_sync_transports_keyed_by_http_version(test_api_key): + # The HTTP version is part of the cache key: without it, whichever caller + # asked second would get a transport pinned to the other version. + reset_sync_api_transports() + config = ConnectionConfig(api_key=test_api_key) + proxied_config = ConnectionConfig( + api_key=test_api_key, + proxy="http://127.0.0.1:9999", + ) + + try: + negotiated = get_sync_transport(config) + http1 = get_sync_transport(config, http2=False) + envd_negotiated = get_sync_envd_transport(config) + envd_http1 = get_sync_envd_transport(config, http2=False) + + assert http1 is not negotiated + assert envd_http1 is not envd_negotiated + assert envd_http1 is not http1 + # Each version still has one pool per proxy, and repeat calls with the + # same arguments reuse it. + assert get_sync_transport(proxied_config, http2=False) not in ( + http1, + negotiated, + ) + assert get_sync_transport(config, http2=False) is http1 + assert get_sync_transport(config) is negotiated + assert get_sync_envd_transport(config, http2=False) is envd_http1 + assert ( + get_sync_envd_transport(config, http2=False, for_streaming=True) + is not envd_http1 + ) + finally: + reset_sync_api_transports() + + +def test_sync_transports_pass_http_version_to_pyqwest(test_api_key, monkeypatch): + # `http_version=None` leaves the version to ALPN (HTTP/2 against the E2B + # API), `HTTP1` pins HTTP/1.1. Which version was negotiated is only + # observable over TLS — the local echo server is plaintext, where both + # settings speak HTTP/1 — so assert what reaches the pyqwest transport. + reset_sync_api_transports() + config = ConnectionConfig(api_key=test_api_key) + captured = [] + build_transport = client_sync.SyncHTTPTransport + + def record(**kwargs): + captured.append(kwargs["http_version"]) + return build_transport(**kwargs) + + monkeypatch.setattr(client_sync, "SyncHTTPTransport", record) + + try: + get_sync_transport(config) + get_sync_transport(config, http2=False) + get_sync_envd_transport(config, http2=False) + + assert captured == [None, HTTPVersion.HTTP1, HTTPVersion.HTTP1] + finally: + reset_sync_api_transports() + + def test_sync_api_client_applies_request_timeout(test_api_key): reset_sync_api_transports() config = ConnectionConfig(api_key=test_api_key, request_timeout=1.5) @@ -219,6 +282,54 @@ async def test_async_get_transport_keyed_by_proxy(test_api_key): reset_async_api_transports() +@pytest.mark.asyncio +async def test_async_transports_keyed_by_http_version(test_api_key): + reset_async_api_transports() + config = ConnectionConfig(api_key=test_api_key) + + try: + negotiated = get_async_transport(config) + http1 = get_async_transport(config, http2=False) + envd_negotiated = get_async_envd_transport(config) + envd_http1 = get_async_envd_transport(config, http2=False) + + assert http1 is not negotiated + assert envd_http1 is not envd_negotiated + assert envd_http1 is not http1 + assert get_async_transport(config, http2=False) is http1 + assert get_async_transport(config) is negotiated + assert get_async_envd_transport(config, http2=False) is envd_http1 + assert ( + get_async_envd_transport(config, http2=False, for_streaming=True) + is not envd_http1 + ) + finally: + reset_async_api_transports() + + +@pytest.mark.asyncio +async def test_async_transports_pass_http_version_to_pyqwest(test_api_key, monkeypatch): + reset_async_api_transports() + config = ConnectionConfig(api_key=test_api_key) + captured = [] + build_transport = client_async.HTTPTransport + + def record(**kwargs): + captured.append(kwargs["http_version"]) + return build_transport(**kwargs) + + monkeypatch.setattr(client_async, "HTTPTransport", record) + + try: + get_async_transport(config) + get_async_transport(config, http2=False) + get_async_envd_transport(config, http2=False) + + assert captured == [None, HTTPVersion.HTTP1, HTTPVersion.HTTP1] + finally: + reset_async_api_transports() + + @pytest.mark.asyncio async def test_async_api_client_is_shared_across_loops(test_api_key): # pyqwest's I/O runs on its own Rust runtime, so neither the transport @@ -591,6 +702,48 @@ async def test_async_api_client_body_timeout_raises_httpx_read_timeout( reset_async_api_transports() +def test_sync_http1_transport_round_trips(test_api_key, echo_server, caplog): + # The HTTP/1.1-pinned transport is functional, not just configured: pinning + # a version reqwest can't use for a request would fail at connect time. + reset_sync_api_transports() + config = ConnectionConfig(api_key=test_api_key) + client = httpx.Client( + base_url=echo_server, transport=get_sync_transport(config, http2=False) + ) + + try: + with caplog.at_level(logging.DEBUG, logger="pyqwest.access"): + response = client.get("/sandboxes") + + assert response.status_code == 200 + assert response.json()["path"] == "/sandboxes" + # pyqwest logs the response's version (the stdlib test server answers + # HTTP/1.0); `httpx.Response.http_version` is not meaningful through the + # adapter, which reports HTTP/1.1 either way. + assert f'GET {echo_server}/sandboxes "HTTP/1.0 200 OK"' in caplog.text + finally: + client.close() + reset_sync_api_transports() + + +@pytest.mark.asyncio +async def test_async_http1_transport_round_trips(test_api_key, echo_server): + reset_async_api_transports() + config = ConnectionConfig(api_key=test_api_key) + client = httpx.AsyncClient( + base_url=echo_server, transport=get_async_transport(config, http2=False) + ) + + try: + response = await client.get("/sandboxes") + + assert response.status_code == 200 + assert response.json()["path"] == "/sandboxes" + finally: + await client.aclose() + reset_async_api_transports() + + def test_sync_transport_sends_multipart_bodies(test_api_key, echo_server): # `files=` uploads (envd `files.write`) go out as httpx's MultipartStream, # which implements both SyncByteStream and AsyncByteStream. The adapter's