fix(python-sdk): restore the http2 parameter on the transport factories (#1671)

The pyqwest migration in 2.38.0 dropped the `http2` parameter from
`get_transport` and `get_envd_transport` (added deliberately in #1347,
2.32.0) and collapsed the transport cache key to the proxy alone, so
`e2b-code-interpreter`'s Jupyter requests —
`get_transport(self.connection_config, http2=False)` — now raise
`TypeError: get_transport() got an unexpected keyword argument 'http2'`;
that is already live, since `e2b = "^2.26.0"` resolves to 2.38.x, and it
blocks the Python half of code-interpreter
[#328](https://github.com/e2b-dev/code-interpreter/pull/328). pyqwest
supports the capability, it just was not threaded through: this restores
the pre-2.38.0 signature (so no consumer code changes, only an `e2b`
floor bump) by passing `http_version=None if http2 else
HTTPVersion.HTTP1` into the pyqwest transports, and puts the HTTP
version back into both cache keys — without that, whichever caller asks
second is handed a transport of the wrong version. The default is
unchanged: `None` leaves TLS connections to ALPN (HTTP/2 against the E2B
API) and uses HTTP/1 for plaintext, exactly as today. HTTP/1.1 is not
cosmetic for the consumer — with HTTP/2 multiplexing, abandoning a
request only resets its stream, so the code-interpreter server never
sees the `http.disconnect` it needs to interrupt the kernel, while
HTTP/1.1's one connection per request closes the connection and the
server observes it.

## Usage

Both factories are internal (nothing is exported from
`e2b/__init__.py`), so there is no public API change; consumers reaching
into them get the 2.32.0 call back:

```python
from e2b.api.client_sync import get_transport, get_envd_transport

# Unchanged: ALPN negotiates the version (HTTP/2 against the E2B API).
transport = get_transport(config)

# Its own pool, pinned to HTTP/1.1, so a cancelled request closes the
# connection and the server observes the disconnect.
http1 = get_transport(config, http2=False)
envd_http1 = get_envd_transport(config, http2=False)
```

The async mirror (`e2b.api.client_async`) is identical.

## Tests

Six new cases in
`packages/python-sdk/tests/test_api_client_transport.py`, sync and
async: cache separation and identity across `http2` / proxy /
`for_streaming`, the `http_version` value actually reaching the pyqwest
transport (`[None, HTTP1, HTTP1]`), and a round trip proving the pinned
transport works. The negotiated version can't be observed locally — the
test echo server is plaintext, where both settings speak HTTP/1 — so it
is asserted at the constructor, with the reason in a comment; it was
verified by hand against `https://api.e2b.app/health` via the
`pyqwest.access` logger, which shows `"HTTP/2 200 OK"` on the default
and `"HTTP/1.1 200 OK"` with `http2=False` on both factories (and
confirms `httpx.Response.http_version` is unreliable through the adapter
— it reports HTTP/1.1 either way). 256 unit tests pass, plus `make
lint`, `make typecheck` and `make format`. No JS change: its transport
is an undici-dispatcher `fetch` with no HTTP-version knob, and the JS
half of code-interpreter #328 is a clean bump.

Closes
[SDK-335](https://linear.app/e2b/issue/SDK-335/python-sdk-get-transport-lost-its-http2-parameter-in-2380-breaking-e2b)

🤖 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-13 17:56:26 +02:00
committed by GitHub
parent ce634ab5f2
commit 0d507cd53d
4 changed files with 240 additions and 33 deletions
@@ -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.
@@ -3,7 +3,7 @@ from typing import Dict, Optional, Tuple, Union
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 @@ class ConnectionRetryTransport(RetryTransport):
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
@@ -3,7 +3,7 @@ from typing import Dict, Optional, Tuple, Union
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 @@ class ConnectionRetryTransport(SyncRetryTransport):
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
@@ -9,6 +9,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
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