Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/python-request-timeout-deadline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@e2b/python-sdk': patch
---

Enforce `request_timeout` at the value given instead of twice it, on control-plane API, envd HTTP (file read/write, health), volume content and template upload requests. The SDK's pyqwest-backed transport derives one whole-request deadline by adding up httpx's `read` and `write` phases, and the SDK set both to the timeout, so `request_timeout=10` gave up only after 20 seconds (the default 60 seconds after 120). Requests that took between the timeout and twice it now time out at the timeout, as documented and as in the JS SDK.
4 changes: 4 additions & 0 deletions packages/python-sdk/e2b/api/client_async/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,14 @@
READ_TIMEOUT,
ConnectionConfig,
HttpVersion,
whole_request_timeout,
)


def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient:
# The transport is pyqwest-backed: it bounds the whole request at
# `request_timeout` only in the shape `whole_request_timeout` builds.
kwargs.setdefault("timeout", whole_request_timeout(config.request_timeout))
return AsyncApiClient(
config,
transport=AsyncRetryableTransport(get_transport(config), config.retries),
Expand Down
4 changes: 4 additions & 0 deletions packages/python-sdk/e2b/api/client_sync/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@
READ_TIMEOUT,
ConnectionConfig,
HttpVersion,
whole_request_timeout,
)


def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient:
# The transport is pyqwest-backed: it bounds the whole request at
# `request_timeout` only in the shape `whole_request_timeout` builds.
kwargs.setdefault("timeout", whole_request_timeout(config.request_timeout))
return ApiClient(
config,
transport=RetryableTransport(get_transport(config), config.retries),
Expand Down
18 changes: 18 additions & 0 deletions packages/python-sdk/e2b/connection_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,24 @@
KEEPALIVE_PING_INTERVAL_SEC = 50 # 50 seconds
KEEPALIVE_PING_HEADER = "Keepalive-Ping-Interval"


def whole_request_timeout(timeout: Optional[float]) -> httpx.Timeout:
"""The ``httpx.Timeout`` that bounds a whole request at ``timeout`` seconds
on the SDK's pyqwest-backed httpx transports (``None``: no deadline).

pyqwest's httpx adapter has no per-phase timeouts: it derives a single
whole-request deadline by adding up the ``read`` and ``write`` phases and
ignores ``connect`` and ``pool`` (the transport bounds connecting on its
own). ``httpx.Timeout(timeout)`` sets both phases, which makes the deadline
twice the timeout, so the whole budget goes on ``read`` and ``write`` stays
at zero. Only for those transports: with httpx's own, a zero ``write``
fails writes (every write on the async client).
"""
if timeout is None:
return httpx.Timeout(None)
return httpx.Timeout(timeout, write=0)


HttpVersion = Literal["1.1", "2"]
DEFAULT_HTTP_VERSION: HttpVersion = "2"
HTTP_VERSIONS: Tuple[HttpVersion, ...] = ("1.1", "2")
Expand Down
9 changes: 7 additions & 2 deletions packages/python-sdk/e2b/envd/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from typing import Callable, Optional

from e2b.connection_config import whole_request_timeout
from e2b.envd.rpc import (
format_sandbox_unreachable_exception,
format_terminated_exception,
Expand Down Expand Up @@ -60,7 +61,9 @@ def check_sandbox_health(envd_api: httpx.Client) -> Optional[bool]:
:raises SandboxUnreachableException: When the proxy reports the sandbox running but envd's port not open -- the exception a request would get.
"""
try:
r = envd_api.get(ENVD_API_HEALTH_ROUTE, timeout=HEALTH_CHECK_TIMEOUT)
r = envd_api.get(
ENVD_API_HEALTH_ROUTE, timeout=whole_request_timeout(HEALTH_CHECK_TIMEOUT)
)
except httpx.TransportError:
raise
except Exception:
Expand All @@ -71,7 +74,9 @@ def check_sandbox_health(envd_api: httpx.Client) -> Optional[bool]:
async def acheck_sandbox_health(envd_api: httpx.AsyncClient) -> Optional[bool]:
"""Async version of :func:`check_sandbox_health`."""
try:
r = await envd_api.get(ENVD_API_HEALTH_ROUTE, timeout=HEALTH_CHECK_TIMEOUT)
r = await envd_api.get(
ENVD_API_HEALTH_ROUTE, timeout=whole_request_timeout(HEALTH_CHECK_TIMEOUT)
)
except httpx.TransportError:
raise
except Exception:
Expand Down
5 changes: 4 additions & 1 deletion packages/python-sdk/e2b/retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,10 @@ def _request_deadline(request: httpx.Request, monotonic: Callable[[], float]) ->
if not isinstance(timeout, dict):
return monotonic() + MAX_RETRY_WAIT_WITHOUT_TIMEOUT_SECONDS

values = [value for value in timeout.values() if value is not None]
# A phase of zero or less holds no budget of its own: the SDK puts a
# request's whole deadline on `read` and leaves `write` at zero (see
# `whole_request_timeout`).
values = [value for value in timeout.values() if value is not None and value > 0]
# A monotonic clock cannot jump when the system wall clock is adjusted,
# keeping elapsed timeout calculations stable across retries.
return monotonic() + (
Expand Down
15 changes: 11 additions & 4 deletions packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
ConnectionConfig,
Username,
default_username,
whole_request_timeout,
)
from e2b.envd.api import (
ENVD_API_FILES_ROUTE,
Expand Down Expand Up @@ -242,7 +243,7 @@ async def read(
ENVD_API_FILES_ROUTE,
params=params,
headers=headers,
timeout=stream_timeout,
timeout=whole_request_timeout(stream_timeout),
)
try:
if stream_idle_timeout:
Expand Down Expand Up @@ -272,7 +273,7 @@ async def read(
ENVD_API_FILES_ROUTE,
params=params,
headers=headers,
timeout=timeout,
timeout=whole_request_timeout(timeout),
)
except httpx.TransportError as e:
raise await ahandle_envd_api_transport_exception_with_health(
Expand Down Expand Up @@ -417,7 +418,9 @@ async def _upload_file(file):
content=to_upload_body_async(file_data, gzip),
headers=headers,
params=params,
timeout=None if is_streamed else upload_timeout,
timeout=whole_request_timeout(
None if is_streamed else upload_timeout
),
)
except httpx.TransportError as e:
raise await ahandle_envd_api_transport_exception_with_health(
Expand Down Expand Up @@ -465,7 +468,11 @@ async def _upload_file(file):
# file-like data was buffered by `_to_httpx_file` (httpx
# rejects text-mode objects in multipart).
timeout=(
None if multipart_body_is_streamed(files) else upload_timeout
whole_request_timeout(
None
if multipart_body_is_streamed(files)
else upload_timeout
)
),
)
except httpx.TransportError as e:
Expand Down
6 changes: 4 additions & 2 deletions packages/python-sdk/e2b/sandbox_async/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from e2b.api.client.types import Unset
from e2b.api.client_async import get_envd_api
from e2b.connection_config import ApiParams, ConnectionConfig
from e2b.connection_config import ApiParams, ConnectionConfig, whole_request_timeout
from e2b.envd.api import ENVD_API_HEALTH_ROUTE, ahandle_envd_api_exception
from e2b.envd.versions import ENVD_DEBUG_FALLBACK
from e2b.exceptions import (
Expand Down Expand Up @@ -153,7 +153,9 @@ async def is_running(self, request_timeout: Optional[float] = None) -> bool:
try:
r = await self._envd_api.get(
ENVD_API_HEALTH_ROUTE,
timeout=self.connection_config.get_request_timeout(request_timeout),
timeout=whole_request_timeout(
self.connection_config.get_request_timeout(request_timeout)
),
)

if r.status_code == 502:
Expand Down
15 changes: 11 additions & 4 deletions packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
ConnectionConfig,
Username,
default_username,
whole_request_timeout,
)

from e2b.envd.api import (
Expand Down Expand Up @@ -228,7 +229,7 @@ def read(
ENVD_API_FILES_ROUTE,
params=params,
headers=headers,
timeout=stream_timeout,
timeout=whole_request_timeout(stream_timeout),
)
try:
r = self._envd_api_streaming.send(request, stream=True)
Expand All @@ -249,7 +250,7 @@ def read(
ENVD_API_FILES_ROUTE,
params=params,
headers=headers,
timeout=timeout,
timeout=whole_request_timeout(timeout),
)
except httpx.TransportError as e:
raise handle_envd_api_transport_exception_with_health(
Expand Down Expand Up @@ -393,7 +394,9 @@ def write_files(
content=to_upload_body(file_data, gzip),
headers=headers,
params=params,
timeout=None if is_streamed else upload_timeout,
timeout=whole_request_timeout(
None if is_streamed else upload_timeout
),
)
except httpx.TransportError as e:
raise handle_envd_api_transport_exception_with_health(
Expand Down Expand Up @@ -435,7 +438,11 @@ def write_files(
# file-like data was buffered by `_to_httpx_file` (httpx
# rejects text-mode objects in multipart).
timeout=(
None if multipart_body_is_streamed(files) else upload_timeout
whole_request_timeout(
None
if multipart_body_is_streamed(files)
else upload_timeout
)
),
)
except httpx.TransportError as e:
Expand Down
6 changes: 4 additions & 2 deletions packages/python-sdk/e2b/sandbox_sync/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from e2b.api.client.types import Unset
from e2b.api.client_sync import get_envd_api
from e2b.connection_config import ApiParams, ConnectionConfig
from e2b.connection_config import ApiParams, ConnectionConfig, whole_request_timeout
from e2b.envd.api import ENVD_API_HEALTH_ROUTE, handle_envd_api_exception
from e2b.envd.versions import ENVD_DEBUG_FALLBACK
from e2b.exceptions import (
Expand Down Expand Up @@ -149,7 +149,9 @@ def is_running(self, request_timeout: Optional[float] = None) -> bool:
try:
r = self._envd_api.get(
ENVD_API_HEALTH_ROUTE,
timeout=self.connection_config.get_request_timeout(request_timeout),
timeout=whole_request_timeout(
self.connection_config.get_request_timeout(request_timeout)
),
)

if r.status_code == 502:
Expand Down
3 changes: 2 additions & 1 deletion packages/python-sdk/e2b/template_async/build_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pyqwest.httpx import AsyncPyqwestTransport

from e2b.api import encode_path_param, handle_api_exception, proxy_to_config
from e2b.connection_config import whole_request_timeout
from e2b.io_utils import aiter_io_chunks
from e2b.api.client.api.templates import (
post_v3_templates,
Expand Down Expand Up @@ -138,7 +139,7 @@ async def upload_file(
# whole-request deadline for the entire transfer, not a per-write
# bound as with the httpx transport this replaced.
async with httpx.AsyncClient(
timeout=httpx.Timeout(upload_timeout),
timeout=whole_request_timeout(upload_timeout),
follow_redirects=api_client._follow_redirects,
transport=AsyncPyqwestTransport(
HTTPTransport(
Expand Down
3 changes: 2 additions & 1 deletion packages/python-sdk/e2b/template_sync/build_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pyqwest.httpx import PyqwestTransport

from e2b.api import encode_path_param, handle_api_exception, proxy_to_config
from e2b.connection_config import whole_request_timeout
from e2b.api.client.api.templates import (
post_v3_templates,
get_templates_template_id_files_hash,
Expand Down Expand Up @@ -136,7 +137,7 @@ def upload_file(
# whole-request deadline for the entire transfer, not a per-write
# bound as with the httpx transport this replaced.
with httpx.Client(
timeout=httpx.Timeout(upload_timeout),
timeout=whole_request_timeout(upload_timeout),
follow_redirects=api_client._follow_redirects,
transport=PyqwestTransport(
SyncHTTPTransport(
Expand Down
6 changes: 2 additions & 4 deletions packages/python-sdk/e2b/volume/client_async/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import httpx
from pyqwest.httpx import AsyncPyqwestTransport

from e2b.api import (
Expand All @@ -7,6 +6,7 @@
)
from e2b.api.client_async import get_httpx_transport
from e2b.api.metadata import default_headers
from e2b.connection_config import whole_request_timeout
from e2b.exceptions import AuthenticationException
from e2b.volume.client.client import AuthenticatedClient as AsyncVolumeApiClient
from e2b.volume.connection_config import READ_TIMEOUT, VolumeConnectionConfig
Expand Down Expand Up @@ -48,9 +48,7 @@ def _api_client(
auth_header_name="Authorization",
prefix="Bearer",
headers=headers,
timeout=(
httpx.Timeout(request_timeout) if request_timeout is not None else None
),
timeout=whole_request_timeout(request_timeout),
httpx_args={
# The proxy lives in the cached transport; passing `proxy` here too
# would mount a fresh, never-closed proxy transport per client.
Expand Down
6 changes: 2 additions & 4 deletions packages/python-sdk/e2b/volume/client_sync/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import httpx
from pyqwest.httpx import PyqwestTransport

from e2b.api import (
Expand All @@ -7,6 +6,7 @@
)
from e2b.api.client_sync import get_httpx_transport
from e2b.api.metadata import default_headers
from e2b.connection_config import whole_request_timeout
from e2b.exceptions import AuthenticationException
from e2b.volume.client.client import AuthenticatedClient as VolumeApiClient
from e2b.volume.connection_config import READ_TIMEOUT, VolumeConnectionConfig
Expand Down Expand Up @@ -48,9 +48,7 @@ def _api_client(
auth_header_name="Authorization",
prefix="Bearer",
headers=headers,
timeout=(
httpx.Timeout(request_timeout) if request_timeout is not None else None
),
timeout=whole_request_timeout(request_timeout),
httpx_args={
# The proxy lives in the cached transport; passing `proxy` here too
# would mount a fresh, never-closed proxy transport per client.
Expand Down
7 changes: 4 additions & 3 deletions packages/python-sdk/e2b/volume/volume_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
ConnectionConfig,
HttpVersion,
ProxyTypes,
whole_request_timeout,
)
from e2b.exceptions import (
NotFoundException,
Expand Down Expand Up @@ -558,7 +559,7 @@ async def stream_file() -> AsyncIterator[bytes]:
method="GET",
url=f"/volumecontent/{self._volume_id}/file",
params=params,
timeout=stream_timeout,
timeout=whole_request_timeout(stream_timeout),
)
response = await read_bounded(stream_cm.__aenter__())
try:
Expand Down Expand Up @@ -592,7 +593,7 @@ async def stream_file() -> AsyncIterator[bytes]:
method="GET",
url=f"/volumecontent/{self._volume_id}/file",
params=params,
timeout=timeout,
timeout=whole_request_timeout(timeout),
)

if response.status_code == 404:
Expand Down Expand Up @@ -644,7 +645,7 @@ async def write_file(
)
api_client = get_volume_api_client(config)
if upload_timeout is not None:
api_client = api_client.with_timeout(httpx.Timeout(upload_timeout))
api_client = api_client.with_timeout(whole_request_timeout(upload_timeout))

content: Union[bytes, AsyncIterator[bytes]]
if isinstance(data, str):
Expand Down
Loading