-
Notifications
You must be signed in to change notification settings - Fork 980
fix(python-sdk): enable TCP keepalive on API and envd HTTP transports #1608
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@e2b/python-sdk': patch | ||
| --- | ||
|
|
||
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,15 +3,22 @@ | |
| import logging | ||
| import os | ||
| import re | ||
| import socket | ||
| import sys | ||
| import threading | ||
| import weakref | ||
| from dataclasses import dataclass | ||
| from types import TracebackType | ||
| from typing import Callable, Optional, Protocol, Union | ||
| from typing import Any, Callable, Iterable, Optional, Protocol, TypeVar, Union, cast | ||
|
|
||
| import httpcore | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 This PR modifies Extended reasoning...BugCLAUDE.md states explicitly: "Generate a changeset when updating packages/cli, packages/js-sdk, packages/python-sdk with Where this shows upThe repo already surfaces this: the Why nothing else catches thisThere is no CI gate in this repo that blocks merge on a missing changeset — the Impact
Proof / how to reproduce
FixRun |
||
| import httpx | ||
| from httpx import AsyncBaseTransport, BaseTransport, Limits, Timeout | ||
|
|
||
| # NOTE: httpx private API. Kept in sync with the `httpx>=0.27.0,<1.0.0` pin in | ||
| # pyproject.toml — re-verify this import when bumping httpx. | ||
| from httpx._utils import get_environment_proxies | ||
|
|
||
| from e2b.api.client.client import AuthenticatedClient | ||
| from e2b.api.client.types import Response | ||
| from e2b.api.metadata import default_headers | ||
|
|
@@ -71,6 +78,183 @@ async def on_response(response: Response) -> None: | |
| connection_retries = int(os.getenv("E2B_CONNECTION_RETRIES") or "3") | ||
|
|
||
|
|
||
| def _get_socket_options( | ||
| platform: str, | ||
| tcp_keepidle: Optional[int], | ||
| tcp_keepalive: Optional[int], | ||
| ) -> tuple[tuple[int, int, int], ...]: | ||
| """Build platform-specific TCP keepalive options for httpcore. | ||
|
|
||
| The 60-second initial-delay tuning uses ``TCP_KEEPIDLE`` where the | ||
| constant is available (Linux and other platforms exposing it) or macOS's | ||
| ``TCP_KEEPALIVE``. Windows CPython also defines ``TCP_KEEPIDLE``, but | ||
| setting it raises ``OSError`` on Windows releases older than 10 1709, so | ||
| Windows only enables ``SO_KEEPALIVE`` and keeps the OS-default probe | ||
| timing. Platforms without either constant likewise fall back to | ||
| enabling keepalive only. | ||
| """ | ||
| options = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)] | ||
| if platform == "win32": | ||
| return tuple(options) | ||
| if tcp_keepidle is not None: | ||
| options.append((socket.IPPROTO_TCP, tcp_keepidle, 60)) | ||
| elif platform == "darwin" and tcp_keepalive is not None: | ||
| options.append((socket.IPPROTO_TCP, tcp_keepalive, 60)) | ||
| return tuple(options) | ||
|
|
||
|
|
||
| _TCP_KEEPALIVE_SOCKET_OPTIONS = _get_socket_options( | ||
| sys.platform, | ||
| getattr(socket, "TCP_KEEPIDLE", None), | ||
| getattr(socket, "TCP_KEEPALIVE", None), | ||
| ) | ||
|
|
||
|
|
||
| class _TCPKeepaliveNetworkBackend(httpcore.NetworkBackend): | ||
| """Force TCP keepalive options through direct and proxy connections.""" | ||
|
|
||
| def __init__(self, backend: httpcore.NetworkBackend): | ||
| self._backend = backend | ||
|
|
||
| def connect_tcp( | ||
| self, | ||
| host: str, | ||
| port: int, | ||
| timeout: Optional[float] = None, | ||
| local_address: Optional[str] = None, | ||
| socket_options: Optional[Iterable[tuple]] = None, | ||
| ) -> httpcore.NetworkStream: | ||
| return self._backend.connect_tcp( | ||
| host, | ||
| port, | ||
| timeout=timeout, | ||
| local_address=local_address, | ||
| socket_options=_TCP_KEEPALIVE_SOCKET_OPTIONS, | ||
| ) | ||
|
|
||
| def connect_unix_socket( | ||
| self, | ||
| path: str, | ||
| timeout: Optional[float] = None, | ||
| socket_options: Optional[Iterable[tuple]] = None, | ||
| ) -> httpcore.NetworkStream: | ||
| return self._backend.connect_unix_socket( | ||
| path, | ||
| timeout=timeout, | ||
| socket_options=socket_options, | ||
| ) | ||
|
|
||
|
|
||
| class _TCPKeepaliveAsyncNetworkBackend(httpcore.AsyncNetworkBackend): | ||
| """Async counterpart of :class:`_TCPKeepaliveNetworkBackend`.""" | ||
|
|
||
| def __init__(self, backend: httpcore.AsyncNetworkBackend): | ||
| self._backend = backend | ||
|
|
||
| async def connect_tcp( | ||
| self, | ||
| host: str, | ||
| port: int, | ||
| timeout: Optional[float] = None, | ||
| local_address: Optional[str] = None, | ||
| socket_options: Optional[Iterable[tuple]] = None, | ||
| ) -> httpcore.AsyncNetworkStream: | ||
| return await self._backend.connect_tcp( | ||
| host, | ||
| port, | ||
| timeout=timeout, | ||
| local_address=local_address, | ||
| socket_options=_TCP_KEEPALIVE_SOCKET_OPTIONS, | ||
| ) | ||
|
|
||
| async def connect_unix_socket( | ||
| self, | ||
| path: str, | ||
| timeout: Optional[float] = None, | ||
| socket_options: Optional[Iterable[tuple]] = None, | ||
| ) -> httpcore.AsyncNetworkStream: | ||
| return await self._backend.connect_unix_socket( | ||
| path, | ||
| timeout=timeout, | ||
| socket_options=socket_options, | ||
| ) | ||
|
|
||
| async def sleep(self, seconds: float) -> None: | ||
| await self._backend.sleep(seconds) | ||
|
|
||
|
|
||
| class _TCPKeepaliveHTTPTransport(httpx.HTTPTransport): | ||
| """HTTPX transport that also covers HTTPcore proxy sockets.""" | ||
|
|
||
| def __init__(self, *args, **kwargs): | ||
| kwargs["socket_options"] = _TCP_KEEPALIVE_SOCKET_OPTIONS | ||
| super().__init__(*args, **kwargs) | ||
| # NOTE: `_pool` / `_network_backend` are httpx/httpcore private API. | ||
| # HTTPcore 1.0.x drops `socket_options` when it builds proxy | ||
| # connections (HTTPProxy/SOCKSProxy), so wrap the backend to force | ||
| # the keepalive options at actual TCP connect time. Verified against | ||
| # the `httpcore>=1.0.5,<2.0.0` pin in pyproject.toml — re-check on bumps. | ||
| pool = cast(Any, self._pool) | ||
| pool._network_backend = _TCPKeepaliveNetworkBackend(pool._network_backend) | ||
|
|
||
|
|
||
| class _TCPKeepaliveAsyncHTTPTransport(httpx.AsyncHTTPTransport): | ||
| """Async HTTPX transport that also covers HTTPcore proxy sockets.""" | ||
|
|
||
| def __init__(self, *args, **kwargs): | ||
| kwargs["socket_options"] = _TCP_KEEPALIVE_SOCKET_OPTIONS | ||
| super().__init__(*args, **kwargs) | ||
| # NOTE: `_pool` / `_network_backend` are httpx/httpcore private API — | ||
| # see _TCPKeepaliveHTTPTransport for the rationale and version pins. | ||
|
Comment on lines
+192
to
+208
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 This PR imports Extended reasoning...What the bug is: Where it manifests: Lines ~195-196 and ~205 of the diff contain comments like:
This comment asserts a specific version constraint ( Why existing code doesn't prevent it: Today this doesn't break anything at runtime, because every supported version of Impact: Two distinct issues, both low severity but real:
Step-by-step proof:
Fix: Add an explicit Severity: This is a nit, not a blocker. It causes no runtime failure today — httpx's own dependency constraints guarantee a compatible httpcore 1.x is always present — so nothing breaks by merging as-is. It's a dependency-hygiene / documentation-accuracy cleanup, appropriate as a follow-up rather than something that should delay this PR. |
||
| pool = cast(Any, self._pool) | ||
| pool._network_backend = _TCPKeepaliveAsyncNetworkBackend(pool._network_backend) | ||
|
|
||
|
|
||
| class _TCPKeepaliveTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): | ||
| """Dual sync/async transport for directly constructed public clients.""" | ||
|
|
||
| def __init__(self, **kwargs): | ||
| self._sync_transport = _TCPKeepaliveHTTPTransport(**kwargs) | ||
| self._async_transport = _TCPKeepaliveAsyncHTTPTransport(**kwargs) | ||
|
|
||
| def handle_request(self, request: httpx.Request) -> httpx.Response: | ||
| return self._sync_transport.handle_request(request) | ||
|
|
||
| async def handle_async_request(self, request: httpx.Request) -> httpx.Response: | ||
| return await self._async_transport.handle_async_request(request) | ||
|
|
||
| def close(self) -> None: | ||
| self._sync_transport.close() | ||
|
|
||
| async def aclose(self) -> None: | ||
| await self._async_transport.aclose() | ||
|
|
||
|
|
||
| # Any transport type produced by the factory passed to | ||
| # _build_env_proxy_mounts; keeps the returned mount dict precisely typed for | ||
| # both sync and async httpx clients. | ||
| _TransportT = TypeVar("_TransportT", bound=Union[BaseTransport, AsyncBaseTransport]) | ||
|
Comment on lines
+210
to
+236
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 This PR adds TCP keepalive to the API and envd transports in Extended reasoning...What the gap is: This PR introduces However, the Volume Content API has its own near-duplicate transport classes that were not touched: Why it matters: Volume file upload/download is precisely the long-lived, potentially-idle connection scenario that TCP keepalive is meant to protect against, per this PR's own stated rationale (idle NAT/load-balancer drops). Both volume transports use the same pooling Why existing code doesn't prevent it: The volume transports are structurally independent classes — they don't inherit from or share a base with Concrete walk-through: (1) A user calls a Volume upload/download API, which reaches Suggested fix: Have On severity: This is not a regression — Volume connections had no keepalive before this PR either, so behavior for Volume traffic is unchanged. The PR's stated scope was explicitly "API and envd HTTP transports," and it does not touch the volume/ directory at all. I'm flagging this as a nit / worthwhile follow-up rather than a blocking issue, since merging as-is doesn't introduce new breakage — it just leaves an inconsistency where Volume traffic remains as exposed to idle-connection drops as it always was, right after a sibling code path fixed the identical problem. |
||
|
|
||
|
|
||
| def _build_env_proxy_mounts( | ||
| transport_factory: Callable[[str], _TransportT], | ||
| ) -> dict[str, Optional[_TransportT]]: | ||
| """Rebuild httpx's environment-proxy mounts for clients that pass an | ||
| explicit ``transport=``. | ||
|
|
||
| Passing an explicit transport to an httpx client disables its | ||
| ``trust_env`` proxy handling entirely, so ``HTTP_PROXY``/``HTTPS_PROXY``/ | ||
| ``ALL_PROXY`` would silently be ignored. This reconstructs the same | ||
| mounts httpx would have built, creating a proxied transport per proxy URL | ||
| via ``transport_factory``. ``NO_PROXY`` entries map to ``None`` mounts, | ||
| which httpx routes to the default (direct) transport. | ||
| """ | ||
| return { | ||
| pattern: None if proxy_url is None else transport_factory(proxy_url) | ||
| for pattern, proxy_url in get_environment_proxies().items() | ||
| } | ||
|
|
||
|
|
||
| @dataclass | ||
| class SandboxCreateResponse: | ||
| sandbox_id: str | ||
|
|
@@ -222,14 +406,25 @@ def __init__( | |
| httpx_args = { | ||
| "event_hooks": self._logging_event_hooks(), | ||
| } | ||
| if transport is not None: | ||
| httpx_args["transport"] = transport | ||
| if ( | ||
| transport is None | ||
| and transport_factory is None | ||
| and async_transport_factory is None | ||
| ): | ||
| httpx_args["proxy"] = config.proxy | ||
| transport_options = {"verify": kwargs.get("verify_ssl", True)} | ||
| transport = _TCPKeepaliveTransport( | ||
| proxy=config.proxy, | ||
| **transport_options, | ||
| ) | ||
| if config.proxy is None: | ||
| httpx_args["mounts"] = _build_env_proxy_mounts( | ||
| lambda proxy_url: _TCPKeepaliveTransport( | ||
| proxy=proxy_url, | ||
| **transport_options, | ||
| ) | ||
| ) | ||
| if transport is not None: | ||
| httpx_args["transport"] = transport | ||
|
|
||
| # config.request_timeout is None when the timeout is explicitly | ||
| # disabled (request_timeout=0), which httpx.Timeout(None) preserves. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This commit changes
packages/python-sdk, but the diff does not add or update any.changeset/*.mdentry, so the release tooling has no patch note/version bump for the transport fix and consumers may not receive it in the next package release. Please add a changeset for the Python SDK change.Useful? React with 👍 / 👎.