Skip to content

Commit d268be9

Browse files
mishushakovclaude
andcommitted
fix(python): build own HTTP/1.1 transport for Jupyter requests
e2b 2.38.0 moved the Python SDK's HTTP stack onto pyqwest, dropping the `http2` argument from the internal `get_transport()` helper. Passing it raised `TypeError` on every `_client` access, and simply dropping it would have left Jupyter requests on ALPN-negotiated HTTP/2, where a cancelled request only resets the stream and the server never sees the disconnect. Build the transport in `e2b_code_interpreter.transport` instead, with `http_version` pinned to HTTP/1.1 and the SDK's pool tuning and connect-only retry policy, so client disconnects still arrive as a TCP close and long-running executions stay cancellable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2e96200 commit d268be9

4 files changed

Lines changed: 103 additions & 34 deletions

File tree

.changeset/shaggy-plums-wave.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@
33
'@e2b/code-interpreter-python': patch
44
---
55

6-
Bump E2B SDK dependency: JavaScript to 2.38.3, Python to 2.38.0
6+
Bump the E2B SDK dependency: JavaScript to 2.38.3, Python to 2.38.0.
7+
8+
The Python SDK moved its HTTP stack onto [`pyqwest`](https://pypi.org/project/pyqwest/), and its internal `get_transport()` helper no longer takes an `http2` argument — the HTTP version is negotiated by ALPN instead, which means HTTP/2 against the sandbox. Jupyter requests build their own HTTP/1.1 transport now (`e2b_code_interpreter.transport`), from the same pool tuning and connect-only retry policy the SDK uses, so client disconnects keep propagating to the server as a TCP close and long-running executions stay reliably cancellable.

python/e2b_code_interpreter/code_interpreter_async.py

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
AsyncSandbox as BaseAsyncSandbox,
99
InvalidArgumentException,
1010
)
11-
from e2b.api.client_async import get_transport
11+
from e2b_code_interpreter.transport import get_async_transport
1212

1313
from e2b_code_interpreter.constants import (
1414
DEFAULT_TEMPLATE,
@@ -70,23 +70,11 @@ def _jupyter_url(self) -> str:
7070

7171
@property
7272
def _client(self) -> AsyncClient:
73-
# TODO: Remove later
74-
# Use a dedicated HTTP/1.1 transport for Jupyter requests.
75-
#
76-
# The base SDK's shared transport now defaults to http2=True. With
77-
# HTTP/2, multiple requests are multiplexed over a single TCP
78-
# connection, so when a client cancels a request (e.g. the caller
79-
# disconnects from the streaming `/execute` endpoint) the server
80-
# may not detect the disconnect: only the HTTP/2 stream is
81-
# cancelled, the underlying TCP connection stays open.
82-
#
83-
# Forcing HTTP/1.1 here keeps the 1:1 mapping between TCP
84-
# connection and request, so client disconnects propagate to the
85-
# server as a TCP close and long-running executions can be
86-
# cancelled reliably. The helper also caches the transport
87-
# per-event-loop for async.
73+
# Use a dedicated HTTP/1.1 transport for Jupyter requests so that
74+
# client disconnects propagate to the server; see the module docstring
75+
# of `e2b_code_interpreter.transport`.
8876
return AsyncClient(
89-
transport=get_transport(self.connection_config, http2=False),
77+
transport=get_async_transport(self.connection_config),
9078
)
9179

9280
async def _handle_connection_error(self, err: Exception) -> None:

python/e2b_code_interpreter/code_interpreter_sync.py

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import Optional, Dict, overload, Union, List
55
from httpx import Client
66
from e2b import Sandbox as BaseSandbox, InvalidArgumentException
7-
from e2b.api.client_sync import get_transport
7+
from e2b_code_interpreter.transport import get_sync_transport
88

99
from e2b_code_interpreter.constants import (
1010
DEFAULT_TEMPLATE,
@@ -67,21 +67,10 @@ def _jupyter_url(self) -> str:
6767

6868
@property
6969
def _client(self) -> Client:
70-
# TODO: Remove later
71-
# Use a dedicated HTTP/1.1 transport for Jupyter requests.
72-
#
73-
# The base SDK's shared transport now defaults to http2=True. With
74-
# HTTP/2, multiple requests are multiplexed over a single TCP
75-
# connection, so when a client cancels a request (e.g. the caller
76-
# disconnects from the streaming `/execute` endpoint) the server
77-
# may not detect the disconnect: only the HTTP/2 stream is
78-
# cancelled, the underlying TCP connection stays open.
79-
#
80-
# Forcing HTTP/1.1 here keeps the 1:1 mapping between TCP
81-
# connection and request, so client disconnects propagate to the
82-
# server as a TCP close and long-running executions can be
83-
# cancelled reliably.
84-
return Client(transport=get_transport(self.connection_config, http2=False))
70+
# Use a dedicated HTTP/1.1 transport for Jupyter requests so that
71+
# client disconnects propagate to the server; see the module docstring
72+
# of `e2b_code_interpreter.transport`.
73+
return Client(transport=get_sync_transport(self.connection_config))
8574

8675
def _handle_connection_error(self, err: Exception) -> None:
8776
"""
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""HTTP/1.1 transports for Jupyter requests.
2+
3+
# TODO: Remove later
4+
The base SDK's shared transports let ALPN negotiate the HTTP version, which
5+
means HTTP/2 against the sandbox. With HTTP/2, multiple requests are
6+
multiplexed over a single TCP connection, so when a client cancels a request
7+
(e.g. the caller disconnects from the streaming `/execute` endpoint) the
8+
server may not detect the disconnect: only the HTTP/2 stream is cancelled,
9+
the underlying TCP connection stays open.
10+
11+
Forcing HTTP/1.1 keeps the 1:1 mapping between TCP connection and request, so
12+
client disconnects propagate to the server as a TCP close, uvicorn delivers
13+
`http.disconnect`, and long-running executions can be cancelled reliably.
14+
15+
The base SDK has no option for this, so we build the transport ourselves from
16+
the same pieces it uses (its pool tuning and connect-only retry policy) with
17+
`http_version` pinned to HTTP/1.1.
18+
"""
19+
20+
import threading
21+
from typing import Dict, Optional
22+
23+
from pyqwest import HTTPTransport, HTTPVersion, SyncHTTPTransport
24+
from pyqwest.httpx import AsyncPyqwestTransport, PyqwestTransport
25+
26+
from e2b.api import (
27+
ProxyConfig,
28+
connection_retries,
29+
pool_idle_timeout,
30+
pool_max_idle_per_host,
31+
proxy_to_config,
32+
)
33+
from e2b.api.client_async import (
34+
ConnectionRetryTransport as AsyncConnectionRetryTransport,
35+
)
36+
from e2b.api.client_sync import ConnectionRetryTransport as SyncConnectionRetryTransport
37+
from e2b.connection_config import ConnectionConfig
38+
39+
_transport_lock = threading.Lock()
40+
# One transport (= one connection pool) per proxy; None is the direct pool.
41+
# pyqwest transports are thread-safe and loop-independent, so the caches are
42+
# process-global rather than per-thread (sync) or per-event-loop (async).
43+
_sync_transports: Dict[Optional[ProxyConfig], PyqwestTransport] = {}
44+
_async_transports: Dict[Optional[ProxyConfig], AsyncPyqwestTransport] = {}
45+
46+
47+
def _transport_kwargs(proxy: Optional[ProxyConfig]) -> dict:
48+
return dict(
49+
# System CA certs, without which TLS through an intercepting proxy
50+
# fails.
51+
tls_include_system_certs=True,
52+
proxy=proxy.to_pyqwest() if proxy is not None else None,
53+
http_version=HTTPVersion.HTTP1,
54+
pool_idle_timeout=pool_idle_timeout,
55+
pool_max_idle_per_host=pool_max_idle_per_host,
56+
# Redirects belong to the httpx client above, not to reqwest.
57+
follow_redirects=False,
58+
)
59+
60+
61+
def get_sync_transport(config: ConnectionConfig) -> PyqwestTransport:
62+
"""The shared HTTP/1.1 transport for synchronous Jupyter requests."""
63+
proxy = proxy_to_config(config.proxy)
64+
with _transport_lock:
65+
transport = _sync_transports.get(proxy)
66+
if transport is None:
67+
transport = PyqwestTransport(
68+
SyncConnectionRetryTransport(
69+
SyncHTTPTransport(**_transport_kwargs(proxy)),
70+
max_retries=connection_retries,
71+
)
72+
)
73+
_sync_transports[proxy] = transport
74+
return transport
75+
76+
77+
def get_async_transport(config: ConnectionConfig) -> AsyncPyqwestTransport:
78+
"""The shared HTTP/1.1 transport for asynchronous Jupyter requests."""
79+
proxy = proxy_to_config(config.proxy)
80+
with _transport_lock:
81+
transport = _async_transports.get(proxy)
82+
if transport is None:
83+
transport = AsyncPyqwestTransport(
84+
AsyncConnectionRetryTransport(
85+
HTTPTransport(**_transport_kwargs(proxy)),
86+
max_retries=connection_retries,
87+
)
88+
)
89+
_async_transports[proxy] = transport
90+
return transport

0 commit comments

Comments
 (0)