Skip to content

Commit 361015d

Browse files
committed
fix(python-sdk): enable TCP keepalive on API and envd HTTP transports
Add keepalive socket options (SO_KEEPALIVE plus TCP_KEEPIDLE / macOS TCP_KEEPALIVE with a 60s initial delay where supported) and keepalive httpx transport subclasses in e2b.api, and wire them into the sync and async API, envd, and proxy transports. On Windows and platforms without a keepalive-idle constant, only SO_KEEPALIVE is set. Custom network backends force the socket options at TCP connect time, since httpcore 1.0.x drops socket_options when constructing proxy connections. Because an explicit transport disables httpx trust_env proxy handling, a shared _build_env_proxy_mounts helper rebuilds the HTTP_PROXY/HTTPS_PROXY/NO_PROXY mounts for ApiClient. Also drop a dead verify parameter from _create_transport.
1 parent b511953 commit 361015d

7 files changed

Lines changed: 500 additions & 38 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@e2b/python-sdk': patch
3+
---
4+
5+
Enable TCP keepalive with a 60-second initial delay where supported across Python SDK HTTP transports, including API, sandbox, volume, proxy, and template upload connections. Environment proxies (`HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`) are now honored by these transports, including template uploads.

packages/python-sdk/e2b/api/__init__.py

Lines changed: 199 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,22 @@
33
import logging
44
import os
55
import re
6+
import socket
7+
import sys
68
import threading
79
import weakref
810
from dataclasses import dataclass
911
from types import TracebackType
10-
from typing import Callable, Optional, Protocol, Union
12+
from typing import Any, Callable, Iterable, Optional, Protocol, TypeVar, Union, cast
1113

14+
import httpcore
1215
import httpx
1316
from httpx import AsyncBaseTransport, BaseTransport, Limits, Timeout
1417

18+
# NOTE: httpx private API. Kept in sync with the `httpx>=0.27.0,<1.0.0` pin in
19+
# pyproject.toml — re-verify this import when bumping httpx.
20+
from httpx._utils import get_environment_proxies
21+
1522
from e2b.api.client.client import AuthenticatedClient
1623
from e2b.api.client.types import Response
1724
from e2b.api.metadata import default_headers
@@ -71,6 +78,183 @@ async def on_response(response: Response) -> None:
7178
connection_retries = int(os.getenv("E2B_CONNECTION_RETRIES") or "3")
7279

7380

81+
def _get_socket_options(
82+
platform: str,
83+
tcp_keepidle: Optional[int],
84+
tcp_keepalive: Optional[int],
85+
) -> tuple[tuple[int, int, int], ...]:
86+
"""Build platform-specific TCP keepalive options for httpcore.
87+
88+
The 60-second initial-delay tuning uses ``TCP_KEEPIDLE`` where the
89+
constant is available (Linux and other platforms exposing it) or macOS's
90+
``TCP_KEEPALIVE``. Windows CPython also defines ``TCP_KEEPIDLE``, but
91+
setting it raises ``OSError`` on Windows releases older than 10 1709, so
92+
Windows only enables ``SO_KEEPALIVE`` and keeps the OS-default probe
93+
timing. Platforms without either constant likewise fall back to
94+
enabling keepalive only.
95+
"""
96+
options = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
97+
if platform == "win32":
98+
return tuple(options)
99+
if tcp_keepidle is not None:
100+
options.append((socket.IPPROTO_TCP, tcp_keepidle, 60))
101+
elif platform == "darwin" and tcp_keepalive is not None:
102+
options.append((socket.IPPROTO_TCP, tcp_keepalive, 60))
103+
return tuple(options)
104+
105+
106+
_TCP_KEEPALIVE_SOCKET_OPTIONS = _get_socket_options(
107+
sys.platform,
108+
getattr(socket, "TCP_KEEPIDLE", None),
109+
getattr(socket, "TCP_KEEPALIVE", None),
110+
)
111+
112+
113+
class _TCPKeepaliveNetworkBackend(httpcore.NetworkBackend):
114+
"""Force TCP keepalive options through direct and proxy connections."""
115+
116+
def __init__(self, backend: httpcore.NetworkBackend):
117+
self._backend = backend
118+
119+
def connect_tcp(
120+
self,
121+
host: str,
122+
port: int,
123+
timeout: Optional[float] = None,
124+
local_address: Optional[str] = None,
125+
socket_options: Optional[Iterable[tuple]] = None,
126+
) -> httpcore.NetworkStream:
127+
return self._backend.connect_tcp(
128+
host,
129+
port,
130+
timeout=timeout,
131+
local_address=local_address,
132+
socket_options=_TCP_KEEPALIVE_SOCKET_OPTIONS,
133+
)
134+
135+
def connect_unix_socket(
136+
self,
137+
path: str,
138+
timeout: Optional[float] = None,
139+
socket_options: Optional[Iterable[tuple]] = None,
140+
) -> httpcore.NetworkStream:
141+
return self._backend.connect_unix_socket(
142+
path,
143+
timeout=timeout,
144+
socket_options=socket_options,
145+
)
146+
147+
148+
class _TCPKeepaliveAsyncNetworkBackend(httpcore.AsyncNetworkBackend):
149+
"""Async counterpart of :class:`_TCPKeepaliveNetworkBackend`."""
150+
151+
def __init__(self, backend: httpcore.AsyncNetworkBackend):
152+
self._backend = backend
153+
154+
async def connect_tcp(
155+
self,
156+
host: str,
157+
port: int,
158+
timeout: Optional[float] = None,
159+
local_address: Optional[str] = None,
160+
socket_options: Optional[Iterable[tuple]] = None,
161+
) -> httpcore.AsyncNetworkStream:
162+
return await self._backend.connect_tcp(
163+
host,
164+
port,
165+
timeout=timeout,
166+
local_address=local_address,
167+
socket_options=_TCP_KEEPALIVE_SOCKET_OPTIONS,
168+
)
169+
170+
async def connect_unix_socket(
171+
self,
172+
path: str,
173+
timeout: Optional[float] = None,
174+
socket_options: Optional[Iterable[tuple]] = None,
175+
) -> httpcore.AsyncNetworkStream:
176+
return await self._backend.connect_unix_socket(
177+
path,
178+
timeout=timeout,
179+
socket_options=socket_options,
180+
)
181+
182+
async def sleep(self, seconds: float) -> None:
183+
await self._backend.sleep(seconds)
184+
185+
186+
class _TCPKeepaliveHTTPTransport(httpx.HTTPTransport):
187+
"""HTTPX transport that also covers HTTPcore proxy sockets."""
188+
189+
def __init__(self, *args, **kwargs):
190+
kwargs["socket_options"] = _TCP_KEEPALIVE_SOCKET_OPTIONS
191+
super().__init__(*args, **kwargs)
192+
# NOTE: `_pool` / `_network_backend` are httpx/httpcore private API.
193+
# HTTPcore 1.0.x drops `socket_options` when it builds proxy
194+
# connections (HTTPProxy/SOCKSProxy), so wrap the backend to force
195+
# the keepalive options at actual TCP connect time. Verified against
196+
# the `httpcore>=1.0.5,<2.0.0` pin in pyproject.toml — re-check on bumps.
197+
pool = cast(Any, self._pool)
198+
pool._network_backend = _TCPKeepaliveNetworkBackend(pool._network_backend)
199+
200+
201+
class _TCPKeepaliveAsyncHTTPTransport(httpx.AsyncHTTPTransport):
202+
"""Async HTTPX transport that also covers HTTPcore proxy sockets."""
203+
204+
def __init__(self, *args, **kwargs):
205+
kwargs["socket_options"] = _TCP_KEEPALIVE_SOCKET_OPTIONS
206+
super().__init__(*args, **kwargs)
207+
# NOTE: `_pool` / `_network_backend` are httpx/httpcore private API —
208+
# see _TCPKeepaliveHTTPTransport for the rationale and version pins.
209+
pool = cast(Any, self._pool)
210+
pool._network_backend = _TCPKeepaliveAsyncNetworkBackend(pool._network_backend)
211+
212+
213+
class _TCPKeepaliveTransport(httpx.BaseTransport, httpx.AsyncBaseTransport):
214+
"""Dual sync/async transport for directly constructed public clients."""
215+
216+
def __init__(self, **kwargs):
217+
self._sync_transport = _TCPKeepaliveHTTPTransport(**kwargs)
218+
self._async_transport = _TCPKeepaliveAsyncHTTPTransport(**kwargs)
219+
220+
def handle_request(self, request: httpx.Request) -> httpx.Response:
221+
return self._sync_transport.handle_request(request)
222+
223+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
224+
return await self._async_transport.handle_async_request(request)
225+
226+
def close(self) -> None:
227+
self._sync_transport.close()
228+
229+
async def aclose(self) -> None:
230+
await self._async_transport.aclose()
231+
232+
233+
# Any transport type produced by the factory passed to
234+
# _build_env_proxy_mounts; keeps the returned mount dict precisely typed for
235+
# both sync and async httpx clients.
236+
_TransportT = TypeVar("_TransportT", bound=Union[BaseTransport, AsyncBaseTransport])
237+
238+
239+
def _build_env_proxy_mounts(
240+
transport_factory: Callable[[str], _TransportT],
241+
) -> dict[str, Optional[_TransportT]]:
242+
"""Rebuild httpx's environment-proxy mounts for clients that pass an
243+
explicit ``transport=``.
244+
245+
Passing an explicit transport to an httpx client disables its
246+
``trust_env`` proxy handling entirely, so ``HTTP_PROXY``/``HTTPS_PROXY``/
247+
``ALL_PROXY`` would silently be ignored. This reconstructs the same
248+
mounts httpx would have built, creating a proxied transport per proxy URL
249+
via ``transport_factory``. ``NO_PROXY`` entries map to ``None`` mounts,
250+
which httpx routes to the default (direct) transport.
251+
"""
252+
return {
253+
pattern: None if proxy_url is None else transport_factory(proxy_url)
254+
for pattern, proxy_url in get_environment_proxies().items()
255+
}
256+
257+
74258
@dataclass
75259
class SandboxCreateResponse:
76260
sandbox_id: str
@@ -222,14 +406,25 @@ def __init__(
222406
httpx_args = {
223407
"event_hooks": self._logging_event_hooks(),
224408
}
225-
if transport is not None:
226-
httpx_args["transport"] = transport
227409
if (
228410
transport is None
229411
and transport_factory is None
230412
and async_transport_factory is None
231413
):
232-
httpx_args["proxy"] = config.proxy
414+
transport_options = {"verify": kwargs.get("verify_ssl", True)}
415+
transport = _TCPKeepaliveTransport(
416+
proxy=config.proxy,
417+
**transport_options,
418+
)
419+
if config.proxy is None:
420+
httpx_args["mounts"] = _build_env_proxy_mounts(
421+
lambda proxy_url: _TCPKeepaliveTransport(
422+
proxy=proxy_url,
423+
**transport_options,
424+
)
425+
)
426+
if transport is not None:
427+
httpx_args["transport"] = transport
233428

234429
# config.request_timeout is None when the timeout is explicitly
235430
# disabled (request_timeout=0), which httpx.Timeout(None) preserves.

packages/python-sdk/e2b/api/client_async/__init__.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
import weakref
33
from typing import Dict, Optional, Tuple
44

5-
import httpx
6-
75
from httpx._types import ProxyTypes
86

9-
from e2b.api import AsyncApiClient, connection_retries, limits
7+
from e2b.api import (
8+
_TCPKeepaliveAsyncHTTPTransport,
9+
AsyncApiClient,
10+
connection_retries,
11+
limits,
12+
)
1013
from e2b.connection_config import ConnectionConfig
1114

1215
TransportKey = Tuple[bool, Optional[ProxyTypes]]
@@ -20,7 +23,7 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient:
2023
)
2124

2225

23-
class AsyncTransportWithLogger(httpx.AsyncHTTPTransport):
26+
class AsyncTransportWithLogger(_TCPKeepaliveAsyncHTTPTransport):
2427
# Keyed weakly by the event loop object itself, not id(loop) — CPython
2528
# reuses object ids, so a new loop could otherwise inherit a transport
2629
# bound to a previous, closed loop.
@@ -34,6 +37,16 @@ def pool(self):
3437
return self._pool
3538

3639

40+
def _create_transport(cls, config: ConnectionConfig, http2: bool):
41+
"""Build a keepalive transport of the given class for this config."""
42+
return cls(
43+
limits=limits,
44+
proxy=config.proxy,
45+
http2=http2,
46+
retries=connection_retries,
47+
)
48+
49+
3750
def _get_cached_transport(cls, config: ConnectionConfig, http2: bool):
3851
loop = asyncio.get_running_loop()
3952
loop_instances = cls._instances.get(loop)
@@ -44,12 +57,7 @@ def _get_cached_transport(cls, config: ConnectionConfig, http2: bool):
4457
key: TransportKey = (http2, config.proxy)
4558
transport = loop_instances.get(key)
4659
if transport is None:
47-
transport = cls(
48-
limits=limits,
49-
proxy=config.proxy,
50-
http2=http2,
51-
retries=connection_retries,
52-
)
60+
transport = _create_transport(cls, config, http2)
5361
loop_instances[key] = transport
5462

5563
return transport

packages/python-sdk/e2b/api/client_sync/__init__.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
from typing import Dict, Optional, Tuple
22

3-
import httpx
43
import threading
54

65
from httpx._types import ProxyTypes
76

8-
from e2b.api import ApiClient, connection_retries, limits
7+
from e2b.api import (
8+
_TCPKeepaliveHTTPTransport,
9+
ApiClient,
10+
connection_retries,
11+
limits,
12+
)
913
from e2b.connection_config import ConnectionConfig
1014

1115
TransportKey = Tuple[bool, Optional[ProxyTypes]]
@@ -19,14 +23,24 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient:
1923
)
2024

2125

22-
class TransportWithLogger(httpx.HTTPTransport):
26+
class TransportWithLogger(_TCPKeepaliveHTTPTransport):
2327
_thread_local = threading.local()
2428

2529
@property
2630
def pool(self):
2731
return self._pool
2832

2933

34+
def _create_transport(cls, config: ConnectionConfig, http2: bool):
35+
"""Build a keepalive transport of the given class for this config."""
36+
return cls(
37+
limits=limits,
38+
proxy=config.proxy,
39+
http2=http2,
40+
retries=connection_retries,
41+
)
42+
43+
3044
def get_transport(config: ConnectionConfig, http2: bool = True) -> TransportWithLogger:
3145
instances: Dict[TransportKey, TransportWithLogger] = getattr(
3246
TransportWithLogger._thread_local, "instances", {}
@@ -36,12 +50,7 @@ def get_transport(config: ConnectionConfig, http2: bool = True) -> TransportWith
3650
if cached is not None:
3751
return cached
3852

39-
transport = TransportWithLogger(
40-
limits=limits,
41-
proxy=config.proxy,
42-
http2=http2,
43-
retries=connection_retries,
44-
)
53+
transport = _create_transport(TransportWithLogger, config, http2)
4554
instances[key] = transport
4655
TransportWithLogger._thread_local.instances = instances
4756
return transport
@@ -62,12 +71,7 @@ def get_envd_transport(
6271
if cached is not None:
6372
return cached
6473

65-
transport = EnvdTransportWithLogger(
66-
limits=limits,
67-
proxy=config.proxy,
68-
http2=http2,
69-
retries=connection_retries,
70-
)
74+
transport = _create_transport(EnvdTransportWithLogger, config, http2)
7175
instances[key] = transport
7276
EnvdTransportWithLogger._thread_local.instances = instances
7377
return transport

packages/python-sdk/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ dependencies = [
1111
"wcmatch>=10.1,<11",
1212
"protobuf-py>=0.1.1,<0.2",
1313
"httpx>=0.27.0,<1.0.0",
14+
"httpcore>=1.0.5,<2.0.0",
1415
"h2>=4,<5",
1516
"attrs>=23.2.0",
1617
"packaging>=24.1",

0 commit comments

Comments
 (0)