From d686a1a54eae1985d8b76fc76128952c7aae5f24 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:24:24 +0000 Subject: [PATCH 1/2] fix(python-sdk): enforce request timeouts as whole-request deadlines pyqwest's httpx adapter approximates an operation timeout as the sum of the httpx read and write phases, so every httpx.Timeout(N) the SDK built was enforced at ~2N (#1881). Map the deadline once in the shared transports (e2b.deadline) and budget retries against the same number; drop the stale code-interpreter comment and let request_timeout=0 disable the timeout there. Co-Authored-By: mish@e2b.dev --- .changeset/python-request-deadline.md | 6 + .../code_interpreter_async.py | 20 +- .../code_interpreter_sync.py | 20 +- .../e2b/api/client_async/__init__.py | 3 +- .../e2b/api/client_sync/__init__.py | 3 +- packages/python-sdk/e2b/deadline.py | 67 ++++++ packages/python-sdk/e2b/retry.py | 15 +- .../e2b/template_async/build_api.py | 10 +- .../python-sdk/e2b/template_sync/build_api.py | 10 +- .../python-sdk/tests/test_request_deadline.py | 198 ++++++++++++++++++ 10 files changed, 312 insertions(+), 40 deletions(-) create mode 100644 .changeset/python-request-deadline.md create mode 100644 packages/python-sdk/e2b/deadline.py create mode 100644 packages/python-sdk/tests/test_request_deadline.py diff --git a/.changeset/python-request-deadline.md b/.changeset/python-request-deadline.md new file mode 100644 index 0000000000..5a396d63e2 --- /dev/null +++ b/.changeset/python-request-deadline.md @@ -0,0 +1,6 @@ +--- +"@e2b/python-sdk": patch +"@e2b/code-interpreter-python": patch +--- + +Enforce `request_timeout` (and other request timeouts such as `run_code(timeout=...)` and template upload timeouts) as a whole-request deadline. pyqwest's httpx adapter sums the httpx `read` and `write` phases, so a timeout of `N` seconds was enforced after `~2N` seconds; the SDK's shared transports now map the deadline once in `e2b.deadline`, and the retry layer budgets against the same deadline. `request_timeout=0` on `run_code` and `create_code_context` now disables the request timeout instead of falling back to the configured default. diff --git a/packages/code-interpreter-python/e2b_code_interpreter/code_interpreter_async.py b/packages/code-interpreter-python/e2b_code_interpreter/code_interpreter_async.py index 50c8e42869..6dc7d9990e 100644 --- a/packages/code-interpreter-python/e2b_code_interpreter/code_interpreter_async.py +++ b/packages/code-interpreter-python/e2b_code_interpreter/code_interpreter_async.py @@ -193,7 +193,7 @@ async def run_code( ) timeout = None if timeout == 0 else (timeout or DEFAULT_TIMEOUT) - request_timeout = request_timeout or self.connection_config.request_timeout + request_timeout = self.connection_config.get_request_timeout(request_timeout) context_id = context.id if context else None try: headers = { @@ -217,16 +217,12 @@ async def run_code( }, headers=headers, # `timeout` bounds the execution, `request_timeout` only the - # connect. Every non-connect phase must carry `timeout`: the - # SDK's pyqwest-backed transport collapses the per-phase - # timeouts into a single whole-request deadline and takes the - # longest of them, so leaving `request_timeout` on the write - # and pool phases would raise the floor to - # `max(timeout, request_timeout)` and silently ignore any - # `timeout` shorter than it. This matches the JS SDK, which - # aborts the execution on a `timeout`-long timer. - # `timeout=0` disables the deadline entirely; the transport - # still bounds connect on its own. + # connect. The SDK's transport enforces the `read` phase as the + # whole-request deadline (see `e2b.deadline`), so `timeout` + # goes there -- matching the JS SDK, which aborts the + # execution on a `timeout`-long timer -- and `request_timeout` + # must stay off it, or it would silently replace a shorter + # `timeout`. `timeout=0` disables the deadline entirely. timeout=( httpx.Timeout(timeout, connect=request_timeout) if timeout is not None @@ -294,7 +290,7 @@ async def create_code_context( f"{self._jupyter_url}/contexts", headers=headers, json=data, - timeout=request_timeout or self.connection_config.request_timeout, + timeout=self.connection_config.get_request_timeout(request_timeout), ) err = await aextract_exception(response) diff --git a/packages/code-interpreter-python/e2b_code_interpreter/code_interpreter_sync.py b/packages/code-interpreter-python/e2b_code_interpreter/code_interpreter_sync.py index 29d43f634e..9dcda4104d 100644 --- a/packages/code-interpreter-python/e2b_code_interpreter/code_interpreter_sync.py +++ b/packages/code-interpreter-python/e2b_code_interpreter/code_interpreter_sync.py @@ -189,7 +189,7 @@ def run_code( ) timeout = None if timeout == 0 else (timeout or DEFAULT_TIMEOUT) - request_timeout = request_timeout or self.connection_config.request_timeout + request_timeout = self.connection_config.get_request_timeout(request_timeout) context_id = context.id if context else None try: @@ -214,16 +214,12 @@ def run_code( }, headers=headers, # `timeout` bounds the execution, `request_timeout` only the - # connect. Every non-connect phase must carry `timeout`: the - # SDK's pyqwest-backed transport collapses the per-phase - # timeouts into a single whole-request deadline and takes the - # longest of them, so leaving `request_timeout` on the write - # and pool phases would raise the floor to - # `max(timeout, request_timeout)` and silently ignore any - # `timeout` shorter than it. This matches the JS SDK, which - # aborts the execution on a `timeout`-long timer. - # `timeout=0` disables the deadline entirely; the transport - # still bounds connect on its own. + # connect. The SDK's transport enforces the `read` phase as the + # whole-request deadline (see `e2b.deadline`), so `timeout` + # goes there -- matching the JS SDK, which aborts the + # execution on a `timeout`-long timer -- and `request_timeout` + # must stay off it, or it would silently replace a shorter + # `timeout`. `timeout=0` disables the deadline entirely. timeout=( httpx.Timeout(timeout, connect=request_timeout) if timeout is not None @@ -291,7 +287,7 @@ def create_code_context( f"{self._jupyter_url}/contexts", json=data, headers=headers, - timeout=request_timeout or self.connection_config.request_timeout, + timeout=self.connection_config.get_request_timeout(request_timeout), ) err = extract_exception(response) diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index 2451772165..64e85a726c 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -8,6 +8,7 @@ from pyqwest.middleware.retry import RetryMode, RetryTransport from e2b.retry import AsyncRetryableTransport +from e2b.deadline import AsyncPyqwestDeadlineTransport from e2b.api import ( AsyncApiClient, ProxyConfig, @@ -165,7 +166,7 @@ def get_httpx_transport( with _transport_lock: transport = _httpx_transports.get(key) if transport is None: - transport = AsyncPyqwestTransport(pool) + transport = AsyncPyqwestDeadlineTransport(pool) _httpx_transports[key] = transport return transport diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index bef2038214..d5927e8d75 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -14,6 +14,7 @@ from pyqwest.middleware.retry import RetryMode, SyncRetryTransport from e2b.retry import RetryableTransport +from e2b.deadline import PyqwestDeadlineTransport from e2b.api import ( ApiClient, ProxyConfig, @@ -170,7 +171,7 @@ def get_httpx_transport( with _transport_lock: transport = _httpx_transports.get(key) if transport is None: - transport = PyqwestTransport(pool) + transport = PyqwestDeadlineTransport(pool) _httpx_transports[key] = transport return transport diff --git a/packages/python-sdk/e2b/deadline.py b/packages/python-sdk/e2b/deadline.py new file mode 100644 index 0000000000..1449d396b9 --- /dev/null +++ b/packages/python-sdk/e2b/deadline.py @@ -0,0 +1,67 @@ +"""Whole-request deadlines over pyqwest's httpx adapter. + +This module stays free of SDK imports: ``e2b.retry`` needs it and is itself +imported by ``e2b.connection_config``, which ``e2b.api`` depends on. +""" + +from typing import Any, Dict, Mapping, Optional + +import httpx +from pyqwest.httpx import AsyncPyqwestTransport, PyqwestTransport + + +def request_deadline(extensions: Mapping[str, Any]) -> Optional[float]: + """Whole-request deadline, in seconds, that an SDK caller attached to a + request through ``httpx.Timeout``; ``None`` when the request is unbounded. + + The SDK's timeouts (``request_timeout``, ``run_code(timeout=...)``, + ``upload_timeout``) bound a request end to end, like the JS SDK's + ``AbortSignal.timeout``. ``httpx.Timeout`` only has per-phase fields, so + callers carry the deadline in its ``read`` phase -- where + ``httpx.Timeout(deadline)`` and ``httpx.Timeout(deadline, connect=...)`` + put it -- and the ``connect``, ``write`` and ``pool`` phases are ignored. + """ + timeout = extensions.get("timeout") + if not isinstance(timeout, dict): + return None + return timeout.get("read") + + +def with_request_deadline(extensions: Mapping[str, Any]) -> Dict[str, Any]: + """Return ``extensions`` with the httpx timeout phases rewritten so pyqwest + enforces exactly :func:`request_deadline`. + + pyqwest's httpx adapter has no deadline input of its own: it approximates + one as ``read + write`` and ignores ``connect`` and ``pool``, so a plain + ``httpx.Timeout(60)`` would be enforced at 120 s. Putting the whole budget + on ``read`` and nothing on ``write`` makes that sum the deadline. + """ + timeout = extensions.get("timeout") + if not isinstance(timeout, dict): + return dict(extensions) + deadline = timeout.get("read") + return { + **extensions, + "timeout": { + **timeout, + "read": deadline, + "write": None if deadline is None else 0, + }, + } + + +class PyqwestDeadlineTransport(PyqwestTransport): + """:class:`PyqwestTransport` that enforces the SDK's whole-request deadline + (see :func:`with_request_deadline`) instead of pyqwest's phase sum.""" + + def handle_request(self, request: httpx.Request) -> httpx.Response: + request.extensions = with_request_deadline(request.extensions) + return super().handle_request(request) + + +class AsyncPyqwestDeadlineTransport(AsyncPyqwestTransport): + """Async twin of :class:`PyqwestDeadlineTransport`.""" + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + request.extensions = with_request_deadline(request.extensions) + return await super().handle_async_request(request) diff --git a/packages/python-sdk/e2b/retry.py b/packages/python-sdk/e2b/retry.py index 50510fccfa..f9f5f05a93 100644 --- a/packages/python-sdk/e2b/retry.py +++ b/packages/python-sdk/e2b/retry.py @@ -6,6 +6,7 @@ import httpx +from e2b.deadline import request_deadline from e2b.exceptions import InvalidArgumentException MAX_RETRY_AFTER_SECONDS = 2_147_483_647 @@ -126,16 +127,14 @@ def _copy_request( def _request_deadline(request: httpx.Request, monotonic: Callable[[], float]) -> float: - timeout = request.extensions.get("timeout") - 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] + # The same whole-request deadline the transport enforces, so retries never + # outlive the request they belong to. + deadline = request_deadline(request.extensions) + if deadline is None: + deadline = MAX_RETRY_WAIT_WITHOUT_TIMEOUT_SECONDS # A monotonic clock cannot jump when the system wall clock is adjusted, # keeping elapsed timeout calculations stable across retries. - return monotonic() + ( - min(values) if values else MAX_RETRY_WAIT_WITHOUT_TIMEOUT_SECONDS - ) + return monotonic() + deadline class RetryableTransport(httpx.BaseTransport): diff --git a/packages/python-sdk/e2b/template_async/build_api.py b/packages/python-sdk/e2b/template_async/build_api.py index 8f0ffc205c..86669a25a3 100644 --- a/packages/python-sdk/e2b/template_async/build_api.py +++ b/packages/python-sdk/e2b/template_async/build_api.py @@ -5,9 +5,13 @@ import httpx from pyqwest import HTTPTransport -from pyqwest.httpx import AsyncPyqwestTransport -from e2b.api import encode_path_param, handle_api_exception, proxy_to_config +from e2b.deadline import AsyncPyqwestDeadlineTransport +from e2b.api import ( + encode_path_param, + handle_api_exception, + proxy_to_config, +) from e2b.io_utils import aiter_io_chunks from e2b.api.client.api.templates import ( post_v3_templates, @@ -140,7 +144,7 @@ async def upload_file( async with httpx.AsyncClient( timeout=httpx.Timeout(upload_timeout), follow_redirects=api_client._follow_redirects, - transport=AsyncPyqwestTransport( + transport=AsyncPyqwestDeadlineTransport( HTTPTransport( tls_include_system_certs=True, proxy=( diff --git a/packages/python-sdk/e2b/template_sync/build_api.py b/packages/python-sdk/e2b/template_sync/build_api.py index 36d915e388..e5366835d8 100644 --- a/packages/python-sdk/e2b/template_sync/build_api.py +++ b/packages/python-sdk/e2b/template_sync/build_api.py @@ -5,9 +5,13 @@ import httpx from pyqwest import SyncHTTPTransport -from pyqwest.httpx import PyqwestTransport -from e2b.api import encode_path_param, handle_api_exception, proxy_to_config +from e2b.deadline import PyqwestDeadlineTransport +from e2b.api import ( + encode_path_param, + handle_api_exception, + proxy_to_config, +) from e2b.api.client.api.templates import ( post_v3_templates, get_templates_template_id_files_hash, @@ -138,7 +142,7 @@ def upload_file( with httpx.Client( timeout=httpx.Timeout(upload_timeout), follow_redirects=api_client._follow_redirects, - transport=PyqwestTransport( + transport=PyqwestDeadlineTransport( SyncHTTPTransport( tls_include_system_certs=True, proxy=( 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..0ff7cd601b --- /dev/null +++ b/packages/python-sdk/tests/test_request_deadline.py @@ -0,0 +1,198 @@ +"""The SDK's timeouts are whole-request deadlines, but ``httpx.Timeout`` only +has per-phase fields and pyqwest's httpx adapter approximates a deadline as +``read + write``, so ``httpx.Timeout(60)`` would be enforced at 120 s (#1881: +``run_code(timeout=N)`` ran for ~2N). ``e2b.deadline`` resolves that in one +place: the ``read`` phase *is* the deadline, the shared transports rewrite the +phases so pyqwest enforces exactly that, and the retry layer budgets against +the same number. +""" + +import asyncio +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import httpx +import pytest +from transport_caches import reset_transport_caches + +from e2b.api.client_async import get_api_client as get_async_api_client +from e2b.api.client_async import get_envd_api as get_async_envd_api +from e2b.api.client_sync import get_api_client, get_envd_api +from e2b.connection_config import ConnectionConfig +from e2b.deadline import request_deadline, with_request_deadline +from e2b.retry import MAX_RETRY_WAIT_WITHOUT_TIMEOUT_SECONDS, _request_deadline + +# Long enough that a request bounded by pyqwest's doubled deadline would still +# be waiting when the assertions below run. +STALL_SECONDS = 3.0 +DEADLINE = 0.5 +# Old behaviour was >= 2 * DEADLINE; the slack absorbs CI scheduling noise. +MAX_ELAPSED = DEADLINE + 0.4 + + +def _extensions(timeout): + return {} if timeout is None else {"timeout": timeout.as_dict()} + + +@pytest.mark.parametrize( + "timeout, deadline", + [ + (httpx.Timeout(60), 60), + (httpx.Timeout(5, connect=60), 5), + (httpx.Timeout(None), None), + (httpx.Timeout(60, read=None), None), + (None, None), + ], +) +def test_request_deadline_is_the_read_phase(timeout, deadline): + assert request_deadline(_extensions(timeout)) == deadline + + +@pytest.mark.parametrize( + "timeout", + [httpx.Timeout(60), httpx.Timeout(5, connect=60), httpx.Timeout(5, write=0.1)], +) +def test_with_request_deadline_makes_pyqwest_phase_sum_the_deadline(timeout): + phases = with_request_deadline(_extensions(timeout))["timeout"] + assert phases["read"] + phases["write"] == request_deadline(_extensions(timeout)) + assert phases["connect"] == timeout.connect + assert phases["pool"] == timeout.pool + + +@pytest.mark.parametrize("timeout", [httpx.Timeout(None), httpx.Timeout(60, read=None)]) +def test_with_request_deadline_keeps_unbounded_requests_unbounded(timeout): + phases = with_request_deadline(_extensions(timeout))["timeout"] + assert phases["read"] is None + assert phases["write"] is None + + +def test_with_request_deadline_leaves_other_extensions_alone(): + assert with_request_deadline({}) == {} + extensions = {"trace": "x", "timeout": httpx.Timeout(1).as_dict()} + assert with_request_deadline(extensions)["trace"] == "x" + assert extensions["timeout"]["write"] == 1 + + +def test_retry_budget_is_the_request_deadline(): + def request(timeout): + return httpx.Request("GET", "http://example", extensions=_extensions(timeout)) + + # Not min(phases): a long execution with a short connect bound keeps its + # full budget for retries. + assert ( + _request_deadline(request(httpx.Timeout(300, connect=60)), lambda: 0.0) == 300 + ) + assert ( + _request_deadline(request(httpx.Timeout(None)), lambda: 0.0) + == MAX_RETRY_WAIT_WITHOUT_TIMEOUT_SECONDS + ) + assert ( + _request_deadline(request(None), lambda: 0.0) + == MAX_RETRY_WAIT_WITHOUT_TIMEOUT_SECONDS + ) + + +class _StallHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def _stall(self): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.flush() + time.sleep(STALL_SECONDS) + try: + self.wfile.write(b"{}") + except OSError: + pass + + do_GET = _stall + do_POST = _stall + + def log_message(self, *args): + pass + + +@pytest.fixture +def stall_server(): + server = ThreadingHTTPServer(("127.0.0.1", 0), _StallHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_address[1]}" + finally: + server.shutdown() + thread.join() + + +def _assert_deadline(elapsed: float): + assert DEADLINE <= elapsed < MAX_ELAPSED, elapsed + + +def test_sync_api_client_request_timeout_is_a_deadline(test_api_key, stall_server): + reset_transport_caches() + config = ConnectionConfig( + api_key=test_api_key, api_url=stall_server, request_timeout=DEADLINE + ) + with get_api_client(config) as client: + started = time.monotonic() + with pytest.raises(httpx.TimeoutException): + client.get_httpx_client().get("/stall") + _assert_deadline(time.monotonic() - started) + + +def test_sync_envd_per_request_timeout_is_a_deadline(test_api_key, stall_server): + reset_transport_caches() + client = get_envd_api(ConnectionConfig(api_key=test_api_key), stall_server) + started = time.monotonic() + with pytest.raises(httpx.TimeoutException): + # How code-interpreter's run_code passes `timeout`: a separate connect + # bound must not stretch the deadline. + client.post("/stall", timeout=httpx.Timeout(DEADLINE, connect=60)) + _assert_deadline(time.monotonic() - started) + + +def test_sync_disabled_timeout_waits_for_the_response(test_api_key, stall_server): + reset_transport_caches() + client = get_envd_api(ConnectionConfig(api_key=test_api_key), stall_server) + response = client.get("/stall", timeout=httpx.Timeout(None)) + assert response.json() == {} + + +@pytest.mark.asyncio +async def test_async_api_client_request_timeout_is_a_deadline( + test_api_key, stall_server +): + reset_transport_caches() + config = ConnectionConfig( + api_key=test_api_key, api_url=stall_server, request_timeout=DEADLINE + ) + async with get_async_api_client(config) as client: + started = time.monotonic() + with pytest.raises(httpx.TimeoutException): + await client.get_async_httpx_client().get("/stall") + _assert_deadline(time.monotonic() - started) + + +@pytest.mark.asyncio +async def test_async_envd_per_request_timeout_is_a_deadline(test_api_key, stall_server): + reset_transport_caches() + client = get_async_envd_api(ConnectionConfig(api_key=test_api_key), stall_server) + started = time.monotonic() + with pytest.raises(httpx.TimeoutException): + await client.post("/stall", timeout=httpx.Timeout(DEADLINE, connect=60)) + _assert_deadline(time.monotonic() - started) + + +@pytest.mark.asyncio +async def test_async_disabled_timeout_waits_for_the_response( + test_api_key, stall_server +): + reset_transport_caches() + client = get_async_envd_api(ConnectionConfig(api_key=test_api_key), stall_server) + response = await asyncio.wait_for( + client.get("/stall", timeout=httpx.Timeout(None)), STALL_SECONDS * 3 + ) + assert response.json() == {} From 89a7d308d1866ae6193811dac6235fffce7af2cb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:25:56 +0000 Subject: [PATCH 2/2] test(code-interpreter): describe the read-phase deadline rule Co-Authored-By: mish@e2b.dev --- .../tests/test_execute_timeout.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/code-interpreter-python/tests/test_execute_timeout.py b/packages/code-interpreter-python/tests/test_execute_timeout.py index 0ea523d93c..92d26545bc 100644 --- a/packages/code-interpreter-python/tests/test_execute_timeout.py +++ b/packages/code-interpreter-python/tests/test_execute_timeout.py @@ -1,11 +1,11 @@ """The execution timeout must reach the transport as the request deadline. -Regression test for the SDK's pyqwest-backed transport collapsing httpx's -per-phase timeouts into a single whole-request deadline equal to the longest -phase. With the previous `(request_timeout, timeout, request_timeout, -request_timeout)` tuple that deadline came out as `max(timeout, -request_timeout)`, so any `timeout` shorter than `request_timeout` was silently -ignored and an execution ran on until `request_timeout` instead. +The SDK's pyqwest-backed transport enforces the `read` phase of the request's +`httpx.Timeout` as the whole-request deadline (see `e2b.deadline`). With the +previous `(request_timeout, timeout, request_timeout, request_timeout)` tuple +the transport's phase arithmetic came out as `max(timeout, request_timeout)`, +so any `timeout` shorter than `request_timeout` was silently ignored and an +execution ran on until `request_timeout` instead. """ import httpx