From b2a5b4dcf64e7bb6bc92d385df0846a0d7b0b01e Mon Sep 17 00:00:00 2001 From: Tanish Gudise <174986239+TanishGudise@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:48:59 -0400 Subject: [PATCH] fix(python-sdk): time out requests at request_timeout instead of twice it The SDK's httpx clients run on pyqwest's httpx adapter, which derives one whole-request deadline by adding up a request's read and write timeouts. httpx.Timeout(N) sets both, so every control-plane, envd HTTP, volume content and template upload request timed out after 2N. A whole_request_timeout() helper puts the whole budget on read and leaves write at zero, used where the SDK knows the transport is pyqwest-backed, and the retry transport no longer treats a zero phase as an exhausted budget. Co-Authored-By: Claude Opus 5.5 --- .changeset/python-request-timeout-deadline.md | 5 + .../e2b/api/client_async/__init__.py | 4 + .../e2b/api/client_sync/__init__.py | 4 + packages/python-sdk/e2b/connection_config.py | 18 ++ packages/python-sdk/e2b/envd/api.py | 9 +- packages/python-sdk/e2b/retry.py | 5 +- .../sandbox_async/filesystem/filesystem.py | 15 +- packages/python-sdk/e2b/sandbox_async/main.py | 6 +- .../e2b/sandbox_sync/filesystem/filesystem.py | 15 +- packages/python-sdk/e2b/sandbox_sync/main.py | 6 +- .../e2b/template_async/build_api.py | 3 +- .../python-sdk/e2b/template_sync/build_api.py | 3 +- .../e2b/volume/client_async/__init__.py | 6 +- .../e2b/volume/client_sync/__init__.py | 6 +- .../python-sdk/e2b/volume/volume_async.py | 7 +- packages/python-sdk/e2b/volume/volume_sync.py | 8 +- .../async/template_async/test_upload_file.py | 8 +- .../sync/template_sync/test_upload_file.py | 8 +- .../tests/test_api_client_transport.py | 19 +- .../tests/test_rate_limit_retry_transport.py | 66 +++++ .../python-sdk/tests/test_request_deadline.py | 264 ++++++++++++++++++ .../python-sdk/tests/test_volume_client.py | 12 +- 22 files changed, 457 insertions(+), 40 deletions(-) create mode 100644 .changeset/python-request-timeout-deadline.md create mode 100644 packages/python-sdk/tests/test_request_deadline.py diff --git a/.changeset/python-request-timeout-deadline.md b/.changeset/python-request-timeout-deadline.md new file mode 100644 index 0000000000..4b630c5312 --- /dev/null +++ b/.changeset/python-request-timeout-deadline.md @@ -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. diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index e8449ec95b..2205e6d171 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -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), diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index 473625e110..43de3df369 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -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), diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 6ad35b37f5..00fdc5d3d0 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -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") diff --git a/packages/python-sdk/e2b/envd/api.py b/packages/python-sdk/e2b/envd/api.py index 21a0ecb7ec..52a138c763 100644 --- a/packages/python-sdk/e2b/envd/api.py +++ b/packages/python-sdk/e2b/envd/api.py @@ -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, @@ -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: @@ -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: diff --git a/packages/python-sdk/e2b/retry.py b/packages/python-sdk/e2b/retry.py index 50510fccfa..f963f4b84b 100644 --- a/packages/python-sdk/e2b/retry.py +++ b/packages/python-sdk/e2b/retry.py @@ -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() + ( diff --git a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py index e59612f926..1d7c835280 100644 --- a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py @@ -14,6 +14,7 @@ ConnectionConfig, Username, default_username, + whole_request_timeout, ) from e2b.envd.api import ( ENVD_API_FILES_ROUTE, @@ -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: @@ -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( @@ -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( @@ -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: diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 3d0a648f50..5d991b9ea6 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -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 ( @@ -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: diff --git a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py index a61936d58b..2a232be1e8 100644 --- a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py @@ -12,6 +12,7 @@ ConnectionConfig, Username, default_username, + whole_request_timeout, ) from e2b.envd.api import ( @@ -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) @@ -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( @@ -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( @@ -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: diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 32c1b671b7..12bf94c2d9 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -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 ( @@ -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: diff --git a/packages/python-sdk/e2b/template_async/build_api.py b/packages/python-sdk/e2b/template_async/build_api.py index 8f0ffc205c..fab3d5e3c5 100644 --- a/packages/python-sdk/e2b/template_async/build_api.py +++ b/packages/python-sdk/e2b/template_async/build_api.py @@ -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, @@ -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( diff --git a/packages/python-sdk/e2b/template_sync/build_api.py b/packages/python-sdk/e2b/template_sync/build_api.py index 36d915e388..c3c21331f1 100644 --- a/packages/python-sdk/e2b/template_sync/build_api.py +++ b/packages/python-sdk/e2b/template_sync/build_api.py @@ -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, @@ -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( diff --git a/packages/python-sdk/e2b/volume/client_async/__init__.py b/packages/python-sdk/e2b/volume/client_async/__init__.py index 2be37945cb..5204120b6d 100644 --- a/packages/python-sdk/e2b/volume/client_async/__init__.py +++ b/packages/python-sdk/e2b/volume/client_async/__init__.py @@ -1,4 +1,3 @@ -import httpx from pyqwest.httpx import AsyncPyqwestTransport from e2b.api import ( @@ -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 @@ -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. diff --git a/packages/python-sdk/e2b/volume/client_sync/__init__.py b/packages/python-sdk/e2b/volume/client_sync/__init__.py index 1cb67a8927..a7a8c0d038 100644 --- a/packages/python-sdk/e2b/volume/client_sync/__init__.py +++ b/packages/python-sdk/e2b/volume/client_sync/__init__.py @@ -1,4 +1,3 @@ -import httpx from pyqwest.httpx import PyqwestTransport from e2b.api import ( @@ -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 @@ -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. diff --git a/packages/python-sdk/e2b/volume/volume_async.py b/packages/python-sdk/e2b/volume/volume_async.py index ec7034a853..ee9f85d69a 100644 --- a/packages/python-sdk/e2b/volume/volume_async.py +++ b/packages/python-sdk/e2b/volume/volume_async.py @@ -26,6 +26,7 @@ ConnectionConfig, HttpVersion, ProxyTypes, + whole_request_timeout, ) from e2b.exceptions import ( NotFoundException, @@ -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: @@ -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: @@ -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): diff --git a/packages/python-sdk/e2b/volume/volume_sync.py b/packages/python-sdk/e2b/volume/volume_sync.py index 187fe6aed8..01be86031d 100644 --- a/packages/python-sdk/e2b/volume/volume_sync.py +++ b/packages/python-sdk/e2b/volume/volume_sync.py @@ -2,7 +2,6 @@ from typing import IO, Iterator, List, Literal, Optional, Union, cast, overload from http import HTTPStatus -import httpx from typing_extensions import Self, Unpack @@ -25,6 +24,7 @@ ConnectionConfig, HttpVersion, ProxyTypes, + whole_request_timeout, ) from e2b.exceptions import ( NotFoundException, @@ -540,7 +540,7 @@ def stream_file() -> Iterator[bytes]: method="GET", url=f"/volumecontent/{self._volume_id}/file", params=params, - timeout=stream_timeout, + timeout=whole_request_timeout(stream_timeout), ) as response: if response.status_code == 404: raise VolumePathNotFoundException(f"Path {path} not found") @@ -562,7 +562,7 @@ def stream_file() -> Iterator[bytes]: method="GET", url=f"/volumecontent/{self._volume_id}/file", params=params, - timeout=timeout, + timeout=whole_request_timeout(timeout), ) if response.status_code == 404: @@ -614,7 +614,7 @@ 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, IO[bytes], Iterator[bytes]] if isinstance(data, str): diff --git a/packages/python-sdk/tests/async/template_async/test_upload_file.py b/packages/python-sdk/tests/async/template_async/test_upload_file.py index 3754639f8b..8b639555a1 100644 --- a/packages/python-sdk/tests/async/template_async/test_upload_file.py +++ b/packages/python-sdk/tests/async/template_async/test_upload_file.py @@ -5,6 +5,7 @@ import httpx import pytest +from pyqwest.httpx._transport import convert_timeout from e2b.api.client.client import AuthenticatedClient from e2b.template import utils as template_utils @@ -174,13 +175,16 @@ async def test_upload_file_defaults_to_one_hour_timeout(tmp_path): # Uploads of large archives need far longer than the 60s general API # timeout, so the default upload timeout is 1 hour (matches the JS SDK). timeout = await _capture_upload_timeout(tmp_path) - assert timeout == httpx.Timeout(FILE_UPLOAD_TIMEOUT_SECONDS) + # The whole-request deadline the pyqwest adapter derives from it. + assert ( + convert_timeout({"timeout": timeout.as_dict()}) == FILE_UPLOAD_TIMEOUT_SECONDS + ) async def test_upload_file_honors_explicit_request_timeout(tmp_path): # An explicitly set request_timeout overrides the 1-hour upload default. timeout = await _capture_upload_timeout(tmp_path, request_timeout=5.0) - assert timeout == httpx.Timeout(5.0) + assert convert_timeout({"timeout": timeout.as_dict()}) == 5.0 async def test_upload_file_ignores_post_upload_close_failure(tmp_path): diff --git a/packages/python-sdk/tests/sync/template_sync/test_upload_file.py b/packages/python-sdk/tests/sync/template_sync/test_upload_file.py index 52bbf7e527..144c9c7ae1 100644 --- a/packages/python-sdk/tests/sync/template_sync/test_upload_file.py +++ b/packages/python-sdk/tests/sync/template_sync/test_upload_file.py @@ -5,6 +5,7 @@ import httpx import pytest +from pyqwest.httpx._transport import convert_timeout from e2b.api.client.client import AuthenticatedClient from e2b.template import utils as template_utils @@ -170,13 +171,16 @@ def test_upload_file_defaults_to_one_hour_timeout(tmp_path): # Uploads of large archives need far longer than the 60s general API # timeout, so the default upload timeout is 1 hour (matches the JS SDK). timeout = _capture_upload_timeout(tmp_path) - assert timeout == httpx.Timeout(FILE_UPLOAD_TIMEOUT_SECONDS) + # The whole-request deadline the pyqwest adapter derives from it. + assert ( + convert_timeout({"timeout": timeout.as_dict()}) == FILE_UPLOAD_TIMEOUT_SECONDS + ) def test_upload_file_honors_explicit_request_timeout(tmp_path): # An explicitly set request_timeout overrides the 1-hour upload default. timeout = _capture_upload_timeout(tmp_path, request_timeout=5.0) - assert timeout == httpx.Timeout(5.0) + assert convert_timeout({"timeout": timeout.as_dict()}) == 5.0 def test_upload_file_ignores_post_upload_close_failure(tmp_path): diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index 9828f2cd0c..82c8ac8fd3 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -12,6 +12,7 @@ import pytest from pyqwest import Headers, HTTPVersion, Request, Response, SyncRequest, SyncResponse from pyqwest.httpx import AsyncPyqwestTransport, PyqwestTransport +from pyqwest.httpx._transport import convert_timeout from transport_caches import reset_transport_caches import e2b.api as api @@ -332,7 +333,8 @@ def test_sync_api_client_applies_request_timeout(test_api_key): httpx_client = api_client.get_httpx_client() try: - assert httpx_client.timeout == httpx.Timeout(1.5) + # The whole-request deadline the pyqwest adapter derives. + assert convert_timeout({"timeout": httpx_client.timeout.as_dict()}) == 1.5 finally: httpx_client.close() reset_transport_caches() @@ -352,6 +354,21 @@ def test_sync_api_client_request_timeout_zero_disables_timeout(test_api_key): reset_transport_caches() +def test_api_client_with_another_transport_keeps_per_phase_timeouts(test_api_key): + # Only the SDK's factories, whose transport is pyqwest-backed, use the + # whole-request shape; httpx's own transport enforces every phase, so a + # zero `write` would fail its writes. + config = ConnectionConfig(api_key=test_api_key, request_timeout=1.5) + httpx_client = api.ApiClient( + config, transport=httpx.HTTPTransport() + ).get_httpx_client() + + try: + assert httpx_client.timeout == httpx.Timeout(1.5) + finally: + httpx_client.close() + + def test_sync_generic_transport_separates_streaming_read_timeout(test_api_key): reset_transport_caches() config = ConnectionConfig(api_key=test_api_key) diff --git a/packages/python-sdk/tests/test_rate_limit_retry_transport.py b/packages/python-sdk/tests/test_rate_limit_retry_transport.py index c4699a8b72..a41387a41c 100644 --- a/packages/python-sdk/tests/test_rate_limit_retry_transport.py +++ b/packages/python-sdk/tests/test_rate_limit_retry_transport.py @@ -402,6 +402,38 @@ def handle_request(self, request): } +def test_retry_budget_ignores_a_zero_write_phase(): + # The SDK puts a request's whole deadline on `read` and leaves `write` at + # zero, because the pyqwest adapter adds the two phases up (see + # `whole_request_timeout`). The zero must not count as an exhausted budget. + clock = FakeClock() + inner = FakeTransport([503, 200], retry_after="") + request = httpx.Request( + "GET", + "https://api.test", + extensions={ + "timeout": {"connect": 3.0, "read": 3.0, "write": 0.0, "pool": 3.0} + }, + ) + + response = RetryableTransport( + inner, + retries=1, + sleep=clock.sleep, + monotonic=clock.monotonic, + random_=lambda: 1.0, + ).handle_request(request) + + assert response.status_code == 200 + assert clock.sleeps == [0.1] + assert inner.requests[1].extensions["timeout"] == { + "connect": pytest.approx(2.9), + "read": pytest.approx(2.9), + "write": 0.0, + "pool": pytest.approx(2.9), + } + + def test_zero_retries_passes_the_original_request_through(): inner = FakeTransport([429], retry_after="0") request = httpx.Request("GET", "https://api.test") @@ -851,6 +883,40 @@ async def sleep(delay): } +@pytest.mark.asyncio +async def test_async_retry_budget_ignores_a_zero_write_phase(): + clock = FakeClock() + inner = FakeAsyncTransport([503, 200], retry_after="") + + async def sleep(delay): + clock.sleep(delay) + + response = await AsyncRetryableTransport( + inner, + retries=1, + sleep=sleep, + monotonic=clock.monotonic, + random_=lambda: 1.0, + ).handle_async_request( + httpx.Request( + "GET", + "https://api.test", + extensions={ + "timeout": {"connect": 3.0, "read": 3.0, "write": 0.0, "pool": 3.0} + }, + ) + ) + + assert response.status_code == 200 + assert clock.sleeps == [0.1] + assert inner.requests[1].extensions["timeout"] == { + "connect": pytest.approx(2.9), + "read": pytest.approx(2.9), + "write": 0.0, + "pool": pytest.approx(2.9), + } + + @pytest.mark.asyncio async def test_async_does_not_retry_streamed_body(): class Stream(httpx.AsyncByteStream): diff --git a/packages/python-sdk/tests/test_request_deadline.py b/packages/python-sdk/tests/test_request_deadline.py new file mode 100644 index 0000000000..05be753ef8 --- /dev/null +++ b/packages/python-sdk/tests/test_request_deadline.py @@ -0,0 +1,264 @@ +""" +The SDK's httpx clients run on pyqwest's httpx adapter, which has no per-phase +timeouts: it derives one whole-request deadline from a request's +``httpx.Timeout`` by adding up its ``read`` and ``write`` phases +(``pyqwest.httpx._transport.convert_timeout``). These tests pin the deadline the +adapter actually derives — not the ``httpx.Timeout`` fields — for each kind of +request the SDK makes: the control-plane API, envd's HTTP API, the volume content +API and template uploads. ``request_timeout=N`` must bound the request at N +seconds, not 2N (#1881). +""" + +import io +from typing import List, Optional + +import httpx +import pytest +from packaging.version import Version +from pyqwest.httpx import AsyncPyqwestTransport, PyqwestTransport +from pyqwest.httpx._transport import convert_timeout + +from e2b import AsyncSandbox, AsyncVolume, Sandbox, Volume +from e2b.api.client.client import AuthenticatedClient +from e2b.api.client_async import get_envd_api as get_async_envd_api +from e2b.api.client_sync import get_envd_api as get_sync_envd_api +from e2b.connection_config import REQUEST_TIMEOUT, ConnectionConfig +from e2b.envd.api import ( + HEALTH_CHECK_TIMEOUT, + acheck_sandbox_health, + check_sandbox_health, +) +from e2b.template_async.build_api import upload_file as async_upload_file +from e2b.template_sync.build_api import upload_file as sync_upload_file + +API_KEY = "e2b_" + "0" * 40 + + +@pytest.fixture +def deadlines(monkeypatch) -> List[Optional[float]]: + """Answer every request at the pyqwest adapter with a 599 and record the + deadline the adapter derives for it.""" + recorded: List[Optional[float]] = [] + + def handle_request(self, request): + recorded.append(convert_timeout(request.extensions)) + return httpx.Response(599, request=request) + + async def handle_async_request(self, request): + recorded.append(convert_timeout(request.extensions)) + return httpx.Response(599, request=request) + + monkeypatch.setattr(PyqwestTransport, "handle_request", handle_request) + monkeypatch.setattr( + AsyncPyqwestTransport, "handle_async_request", handle_async_request + ) + return recorded + + +def _sandbox_opts(**config): + return dict( + sandbox_id="sbx", + sandbox_domain="e2b.app", + envd_version=Version("0.5.0"), + envd_access_token=None, + traffic_access_token=None, + connection_config=ConnectionConfig(api_key=API_KEY, **config), + ) + + +def _sync_calls(tmp_path): + sandbox = Sandbox(**_sandbox_opts()) + volume = Volume("vol", "name", token="vol-token") + (tmp_path / "a.txt").write_text("a") + return { + "api": ( + lambda: Sandbox.get_info("sbx", api_key=API_KEY, request_timeout=2.5), + 2.5, + ), + "api default": ( + lambda: Sandbox.get_info("sbx", api_key=API_KEY), + REQUEST_TIMEOUT, + ), + "envd read": (lambda: sandbox.files.read("/f", request_timeout=2.5), 2.5), + "envd read stream": ( + lambda: sandbox.files.read("/f", format="stream", request_timeout=2.5), + 2.5, + ), + "envd write": ( + lambda: sandbox.files.write("/f", "data", request_timeout=2.5), + 2.5, + ), + "envd write multipart": ( + lambda: sandbox.files.write_files( + [{"path": "/a", "data": "a"}, {"path": "/b", "data": "b"}], + request_timeout=2.5, + ), + 2.5, + ), + "envd is_running": (lambda: sandbox.is_running(request_timeout=2.5), 2.5), + "envd health probe": ( + lambda: check_sandbox_health( + get_sync_envd_api( + ConnectionConfig(api_key=API_KEY), + "http://sbx.test", + retry_connect=False, + ) + ), + HEALTH_CHECK_TIMEOUT, + ), + "volume read": (lambda: volume.read_file("/f", request_timeout=2.5), 2.5), + "volume write": ( + lambda: volume.write_file("/f", "data", request_timeout=2.5), + 2.5, + ), + "template upload": ( + lambda: sync_upload_file( + api_client=AuthenticatedClient(base_url="http://test", token="t"), + file_name="*.txt", + context_path=str(tmp_path), + url="http://upload.test/archive", + ignore_patterns=[], + resolve_symlinks=False, + gzip=True, + stack_trace=None, + request_timeout=2.5, + ), + 2.5, + ), + } + + +def _async_calls(tmp_path): + sandbox = AsyncSandbox(**_sandbox_opts()) + volume = AsyncVolume("vol", "name", token="vol-token") + (tmp_path / "a.txt").write_text("a") + return { + "api": ( + lambda: AsyncSandbox.get_info("sbx", api_key=API_KEY, request_timeout=2.5), + 2.5, + ), + "api default": ( + lambda: AsyncSandbox.get_info("sbx", api_key=API_KEY), + REQUEST_TIMEOUT, + ), + "envd read": (lambda: sandbox.files.read("/f", request_timeout=2.5), 2.5), + "envd read stream": ( + lambda: sandbox.files.read("/f", format="stream", request_timeout=2.5), + 2.5, + ), + "envd write": ( + lambda: sandbox.files.write("/f", "data", request_timeout=2.5), + 2.5, + ), + "envd write multipart": ( + lambda: sandbox.files.write_files( + [{"path": "/a", "data": "a"}, {"path": "/b", "data": "b"}], + request_timeout=2.5, + ), + 2.5, + ), + "envd is_running": (lambda: sandbox.is_running(request_timeout=2.5), 2.5), + "envd health probe": ( + lambda: acheck_sandbox_health( + get_async_envd_api( + ConnectionConfig(api_key=API_KEY), + "http://sbx.test", + retry_connect=False, + ) + ), + HEALTH_CHECK_TIMEOUT, + ), + "volume read": (lambda: volume.read_file("/f", request_timeout=2.5), 2.5), + "volume write": ( + lambda: volume.write_file("/f", "data", request_timeout=2.5), + 2.5, + ), + "template upload": ( + lambda: async_upload_file( + api_client=AuthenticatedClient(base_url="http://test", token="t"), + file_name="*.txt", + context_path=str(tmp_path), + url="http://upload.test/archive", + ignore_patterns=[], + resolve_symlinks=False, + gzip=True, + stack_trace=None, + request_timeout=2.5, + ), + 2.5, + ), + } + + +CASES = [ + "api", + "api default", + "envd read", + "envd read stream", + "envd write", + "envd write multipart", + "envd is_running", + "envd health probe", + "volume read", + "volume write", + "template upload", +] + + +@pytest.mark.parametrize("case", CASES) +def test_sync_request_deadline_is_the_requested_timeout(case, deadlines, tmp_path): + call, expected = _sync_calls(tmp_path)[case] + try: + call() + except Exception: + pass # the stubbed 599 answer; only the deadline matters here + + assert deadlines, "the request never reached the transport" + assert deadlines[0] == expected + + +@pytest.mark.parametrize("case", CASES) +async def test_async_request_deadline_is_the_requested_timeout( + case, deadlines, tmp_path +): + call, expected = _async_calls(tmp_path)[case] + try: + await call() + except Exception: + pass # the stubbed 599 answer; only the deadline matters here + + assert deadlines, "the request never reached the transport" + assert deadlines[0] == expected + + +def test_sync_disabled_and_streamed_requests_stay_unbounded(deadlines): + # `request_timeout=0` disables the deadline, a streamed download without an + # explicit `request_timeout` has none (an idle read bound applies instead), + # and a streamed upload drops it — all unchanged. + sandbox = Sandbox(**_sandbox_opts()) + for call in ( + lambda: Sandbox.get_info("sbx", api_key=API_KEY, request_timeout=0), + lambda: sandbox.files.read("/f", format="stream"), + lambda: sandbox.files.write("/f", io.BytesIO(b"data")), + ): + try: + call() + except Exception: + pass + + assert deadlines == [None, None, None] + + +async def test_async_disabled_and_streamed_requests_stay_unbounded(deadlines): + sandbox = AsyncSandbox(**_sandbox_opts()) + for call in ( + lambda: AsyncSandbox.get_info("sbx", api_key=API_KEY, request_timeout=0), + lambda: sandbox.files.read("/f", format="stream"), + lambda: sandbox.files.write("/f", io.BytesIO(b"data")), + ): + try: + await call() + except Exception: + pass + + assert deadlines == [None, None, None] diff --git a/packages/python-sdk/tests/test_volume_client.py b/packages/python-sdk/tests/test_volume_client.py index d2bdab2b55..6e8f69e108 100644 --- a/packages/python-sdk/tests/test_volume_client.py +++ b/packages/python-sdk/tests/test_volume_client.py @@ -7,6 +7,7 @@ import httpx import pytest from pyqwest.httpx import AsyncPyqwestTransport, PyqwestTransport +from pyqwest.httpx._transport import convert_timeout from transport_caches import reset_transport_caches @@ -50,14 +51,19 @@ def test_async_client_requires_volume_token(monkeypatch): get_async_api_client(VolumeConnectionConfig()) +def _deadline(timeout: httpx.Timeout): + # The whole-request deadline the pyqwest adapter derives from the timeout. + return convert_timeout({"timeout": timeout.as_dict()}) + + def test_sync_client_uses_config_request_timeout(): client = get_sync_api_client(VolumeConnectionConfig(token="vol-token")) - assert client.get_httpx_client().timeout == httpx.Timeout(60.0) + assert _deadline(client.get_httpx_client().timeout) == 60.0 client = get_sync_api_client( VolumeConnectionConfig(token="vol-token", request_timeout=10.0) ) - assert client.get_httpx_client().timeout == httpx.Timeout(10.0) + assert _deadline(client.get_httpx_client().timeout) == 10.0 client = get_sync_api_client( VolumeConnectionConfig(token="vol-token", request_timeout=0) @@ -68,7 +74,7 @@ def test_sync_client_uses_config_request_timeout(): def test_async_client_uses_config_request_timeout(): async def run(): client = get_async_api_client(VolumeConnectionConfig(token="vol-token")) - assert client.get_async_httpx_client().timeout == httpx.Timeout(60.0) + assert _deadline(client.get_async_httpx_client().timeout) == 60.0 client = get_async_api_client( VolumeConnectionConfig(token="vol-token", request_timeout=0)