Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/python-request-deadline.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 6 additions & 6 deletions packages/code-interpreter-python/tests/test_execute_timeout.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/python-sdk/e2b/api/client_async/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion packages/python-sdk/e2b/api/client_sync/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
67 changes: 67 additions & 0 deletions packages/python-sdk/e2b/deadline.py
Original file line number Diff line number Diff line change
@@ -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)
15 changes: 7 additions & 8 deletions packages/python-sdk/e2b/retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
10 changes: 7 additions & 3 deletions packages/python-sdk/e2b/template_async/build_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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=(
Expand Down
10 changes: 7 additions & 3 deletions packages/python-sdk/e2b/template_sync/build_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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=(
Expand Down
Loading
Loading