From d89eff8469ac7f779d7f1b1cb2063cc435396270 Mon Sep 17 00:00:00 2001 From: BlueX888 <140241684+BlueX888@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:09:16 +0800 Subject: [PATCH 1/4] fix(code-interpreter): abort Jupyter requests at the requested deadline The pyqwest-backed transport has no per-phase timeouts: it collapses httpx's phases into a single whole-request deadline, derived as the sum of the `read` and `write` phases (`connect` and `pool` are ignored). Every Jupyter request set both phases to the timeout it asked for, so the deadline came out at `2 * timeout`: `run_code(timeout=1)` aborting after 2s, `run_code(timeout=3)` not being cut at 3s at all, and the context requests running on for twice their `request_timeout`. Put the whole budget on the read phase and leave write at zero, so the derived deadline is the timeout the caller passed. tests/test_execute_timeout.py now pins the deadline the transport actually derives, against a local server that sends the response headers and then stalls before the body, instead of only the httpx.Timeout fields that fed into it. --- .changeset/wild-donkeys-hunt.md | 5 + .../code_interpreter_async.py | 27 ++-- .../code_interpreter_sync.py | 27 ++-- .../tests/test_execute_timeout.py | 135 ++++++++++++++++-- 4 files changed, 153 insertions(+), 41 deletions(-) create mode 100644 .changeset/wild-donkeys-hunt.md diff --git a/.changeset/wild-donkeys-hunt.md b/.changeset/wild-donkeys-hunt.md new file mode 100644 index 0000000000..b320923dbb --- /dev/null +++ b/.changeset/wild-donkeys-hunt.md @@ -0,0 +1,5 @@ +--- +'@e2b/code-interpreter-python': patch +--- + +Abort Jupyter requests at the deadline they were given instead of twice it. The SDK's pyqwest-backed transport derives a single whole-request deadline by summing httpx's `read` and `write` phases, and every request set both to the requested timeout — so `run_code(timeout=1)` ran for up to 2 seconds, and the context requests (`request_timeout`) likewise bought twice their value. 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..cff0bb1c0a 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 @@ -217,18 +217,17 @@ 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. + # connect. The SDK's pyqwest-backed transport has no per-phase + # timeouts: it collapses them into a single whole-request + # deadline, derived as the sum of the `read` and `write` phases + # (`connect` and `pool` are ignored). The whole budget therefore + # goes on `read` and `write` stays at zero, which makes the + # deadline `timeout` and not `2 * timeout`. 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. timeout=( - httpx.Timeout(timeout, connect=request_timeout) + httpx.Timeout(timeout, write=0, connect=request_timeout) if timeout is not None else httpx.Timeout(None) ), @@ -294,7 +293,9 @@ async def create_code_context( f"{self._jupyter_url}/contexts", headers=headers, json=data, - timeout=request_timeout or self.connection_config.request_timeout, + timeout=httpx.Timeout( + request_timeout or self.connection_config.request_timeout, write=0 + ), ) err = await aextract_exception(response) @@ -334,7 +335,7 @@ async def remove_code_context( response = await self._client.delete( f"{self._jupyter_url}/contexts/{context_id}", headers=headers, - timeout=self.connection_config.request_timeout, + timeout=httpx.Timeout(self.connection_config.request_timeout, write=0), ) err = await aextract_exception(response) @@ -364,7 +365,7 @@ async def list_code_contexts(self) -> List[Context]: response = await self._client.get( f"{self._jupyter_url}/contexts", headers=headers, - timeout=self.connection_config.request_timeout, + timeout=httpx.Timeout(self.connection_config.request_timeout, write=0), ) err = await aextract_exception(response) @@ -403,7 +404,7 @@ async def restart_code_context( response = await self._client.post( f"{self._jupyter_url}/contexts/{context_id}/restart", headers=headers, - timeout=self.connection_config.request_timeout, + timeout=httpx.Timeout(self.connection_config.request_timeout, write=0), ) 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..04098b9631 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 @@ -214,18 +214,17 @@ 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. + # connect. The SDK's pyqwest-backed transport has no per-phase + # timeouts: it collapses them into a single whole-request + # deadline, derived as the sum of the `read` and `write` phases + # (`connect` and `pool` are ignored). The whole budget therefore + # goes on `read` and `write` stays at zero, which makes the + # deadline `timeout` and not `2 * timeout`. 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. timeout=( - httpx.Timeout(timeout, connect=request_timeout) + httpx.Timeout(timeout, write=0, connect=request_timeout) if timeout is not None else httpx.Timeout(None) ), @@ -291,7 +290,9 @@ def create_code_context( f"{self._jupyter_url}/contexts", json=data, headers=headers, - timeout=request_timeout or self.connection_config.request_timeout, + timeout=httpx.Timeout( + request_timeout or self.connection_config.request_timeout, write=0 + ), ) err = extract_exception(response) @@ -331,7 +332,7 @@ def remove_code_context( response = self._client.delete( f"{self._jupyter_url}/contexts/{context_id}", headers=headers, - timeout=self.connection_config.request_timeout, + timeout=httpx.Timeout(self.connection_config.request_timeout, write=0), ) err = extract_exception(response) @@ -361,7 +362,7 @@ def list_code_contexts(self) -> List[Context]: response = self._client.get( f"{self._jupyter_url}/contexts", headers=headers, - timeout=self.connection_config.request_timeout, + timeout=httpx.Timeout(self.connection_config.request_timeout, write=0), ) err = extract_exception(response) @@ -401,7 +402,7 @@ def restart_code_context( response = self._client.post( f"{self._jupyter_url}/contexts/{context_id}/restart", headers=headers, - timeout=self.connection_config.request_timeout, + timeout=httpx.Timeout(self.connection_config.request_timeout, write=0), ) err = extract_exception(response) diff --git a/packages/code-interpreter-python/tests/test_execute_timeout.py b/packages/code-interpreter-python/tests/test_execute_timeout.py index 0ea523d93c..bdc3fb5d84 100644 --- a/packages/code-interpreter-python/tests/test_execute_timeout.py +++ b/packages/code-interpreter-python/tests/test_execute_timeout.py @@ -1,16 +1,20 @@ """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. +per-phase timeouts into a single whole-request deadline, which it derives as +the sum of the `read` and `write` phases (`connect` and `pool` are ignored). +Setting both phases to `timeout` therefore gave a deadline of `2 * timeout`, +so an execution was aborted at twice the `timeout` it asked for. """ +import socket +import threading +import time + import httpx import pytest +from e2b import TimeoutException from e2b.connection_config import ConnectionConfig from e2b_code_interpreter.code_interpreter_async import AsyncSandbox @@ -35,7 +39,7 @@ def stream(self, *args, **kwargs): raise _Stop -def _sandbox(cls, captured: dict): +def _sandbox(cls, captured=None, connection_config=None, url="http://127.0.0.1:9"): class _Fake(cls): @property def connection_config(self): @@ -57,10 +61,12 @@ def traffic_access_token(self): @property def _jupyter_url(self): - return "http://127.0.0.1:9" + return url @property def _client(self): + if captured is None: + return super()._client return _CapturingClient(captured) return _Fake.__new__(_Fake) @@ -69,12 +75,55 @@ def _client(self): def _captured_timeout(captured: dict) -> httpx.Timeout: timeout = captured["timeout"] assert isinstance(timeout, httpx.Timeout), ( - "a plain tuple lets the transport pick the longest phase as the deadline; " - f"got {timeout!r}" + f"expected an httpx.Timeout carrying the deadline; got {timeout!r}" ) return timeout +class _StallingJupyterServer: + """Plays a Jupyter server that sends the headers, then stalls. + + The body only follows `stall` seconds later, so the deadline the transport + derives from the request timeout is the only thing that can end the request + before then. + """ + + RESULT_LINE = b'{"type":"result","is_main_result":true,"text":"42"}\n' + + def __init__(self, stall: float): + self._stall = stall + self._socket = socket.socket() + self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._socket.bind(("127.0.0.1", 0)) + self._socket.listen(1) + self.url = f"http://127.0.0.1:{self._socket.getsockname()[1]}" + + def __enter__(self): + threading.Thread(target=self._serve, daemon=True).start() + return self + + def __exit__(self, *_): + self._socket.close() + + def _serve(self): + try: + connection, _ = self._socket.accept() + except OSError: + return + with connection: + connection.recv(65536) + connection.sendall( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: application/x-ndjson\r\n" + b"Connection: close\r\n\r\n" + ) + time.sleep(self._stall) + try: + connection.sendall(self.RESULT_LINE) + except OSError: + pass + + @pytest.mark.parametrize("timeout", [3, 10, 300]) def test_execution_timeout_is_the_request_deadline(timeout): captured: dict = {} @@ -82,11 +131,10 @@ def test_execution_timeout_is_the_request_deadline(timeout): _sandbox(Sandbox, captured).run_code("1 + 1", timeout=timeout) tmo = _captured_timeout(captured) - # Every non-connect phase carries `timeout`, so the deadline the transport - # derives is `timeout` and not `max(timeout, request_timeout)`. + # The transport derives its deadline by summing `read` and `write`, so the + # whole budget sits on one phase and the other contributes nothing. assert tmo.read == timeout - assert tmo.write == timeout - assert tmo.pool == timeout + assert tmo.write == 0 assert tmo.connect == REQUEST_TIMEOUT @@ -98,8 +146,7 @@ async def test_async_execution_timeout_is_the_request_deadline(timeout): tmo = _captured_timeout(captured) assert tmo.read == timeout - assert tmo.write == timeout - assert tmo.pool == timeout + assert tmo.write == 0 assert tmo.connect == REQUEST_TIMEOUT @@ -121,3 +168,61 @@ async def test_async_zero_timeout_disables_the_deadline(): tmo = _captured_timeout(captured) assert (tmo.read, tmo.write, tmo.pool, tmo.connect) == (None, None, None, None) + + +# Long enough that a deadline of twice the requested timeout also elapses +# before the server sends the body. +STALL = 4.0 + + +def test_execution_is_aborted_after_timeout_seconds(): + with _StallingJupyterServer(STALL) as server: + started = time.monotonic() + with pytest.raises(TimeoutException, match="Execution timed out"): + _sandbox(Sandbox, url=server.url).run_code("1 + 1", timeout=1) + elapsed = time.monotonic() - started + + assert elapsed < 1.5, f"run_code(timeout=1) was aborted after {elapsed:.2f}s" + + +async def test_async_execution_is_aborted_after_timeout_seconds(): + with _StallingJupyterServer(STALL) as server: + started = time.monotonic() + with pytest.raises(TimeoutException, match="Execution timed out"): + await _sandbox(AsyncSandbox, url=server.url).run_code("1 + 1", timeout=1) + elapsed = time.monotonic() - started + + assert elapsed < 1.5, f"run_code(timeout=1) was aborted after {elapsed:.2f}s" + + +def test_execution_within_timeout_is_not_aborted(): + with _StallingJupyterServer(1.0) as server: + execution = _sandbox(Sandbox, url=server.url).run_code("1 + 1", timeout=5) + + assert execution.text == "42" + + +def test_context_request_is_aborted_after_request_timeout_seconds(): + config = ConnectionConfig(api_key="x", domain="e2b.app", request_timeout=1) + with _StallingJupyterServer(STALL) as server: + started = time.monotonic() + with pytest.raises(TimeoutException, match="Request timed out"): + _sandbox( + Sandbox, connection_config=config, url=server.url + ).list_code_contexts() + elapsed = time.monotonic() - started + + assert elapsed < 1.5, f"list_code_contexts() was aborted after {elapsed:.2f}s" + + +async def test_async_context_request_is_aborted_after_request_timeout_seconds(): + config = ConnectionConfig(api_key="x", domain="e2b.app", request_timeout=1) + with _StallingJupyterServer(STALL) as server: + started = time.monotonic() + with pytest.raises(TimeoutException, match="Request timed out"): + await _sandbox( + AsyncSandbox, connection_config=config, url=server.url + ).list_code_contexts() + elapsed = time.monotonic() - started + + assert elapsed < 1.5, f"list_code_contexts() was aborted after {elapsed:.2f}s" From 9774a4963e4d9e1afa46b8678fa3edf52f1878ae Mon Sep 17 00:00:00 2001 From: BlueX888 <140241684+BlueX888@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:48:36 +0800 Subject: [PATCH 2/4] test(code-interpreter): assert the deadline the transport derives The field assertions say what the SDK puts on the request; this says what the transport makes of it. `convert_timeout` sums `read` and `write`, so the derived deadline has to come out as the timeout the caller asked for and not twice it. --- .../code-interpreter-python/tests/test_execute_timeout.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/code-interpreter-python/tests/test_execute_timeout.py b/packages/code-interpreter-python/tests/test_execute_timeout.py index bdc3fb5d84..550a1908df 100644 --- a/packages/code-interpreter-python/tests/test_execute_timeout.py +++ b/packages/code-interpreter-python/tests/test_execute_timeout.py @@ -13,6 +13,7 @@ import httpx import pytest +from pyqwest.httpx._transport import convert_timeout from e2b import TimeoutException from e2b.connection_config import ConnectionConfig @@ -80,6 +81,11 @@ def _captured_timeout(captured: dict) -> httpx.Timeout: return timeout +def _derived_deadline(timeout: httpx.Timeout) -> float | None: + """The single operation deadline the transport derives for a request.""" + return convert_timeout({"timeout": timeout.as_dict()}) + + class _StallingJupyterServer: """Plays a Jupyter server that sends the headers, then stalls. @@ -136,6 +142,7 @@ def test_execution_timeout_is_the_request_deadline(timeout): assert tmo.read == timeout assert tmo.write == 0 assert tmo.connect == REQUEST_TIMEOUT + assert _derived_deadline(tmo) == timeout @pytest.mark.parametrize("timeout", [3, 10, 300]) @@ -148,6 +155,7 @@ async def test_async_execution_timeout_is_the_request_deadline(timeout): assert tmo.read == timeout assert tmo.write == 0 assert tmo.connect == REQUEST_TIMEOUT + assert _derived_deadline(tmo) == timeout def test_zero_timeout_disables_the_deadline(): From 5af8afdcead628c66ae8dda157825ebaf6a85017 Mon Sep 17 00:00:00 2001 From: BlueX888 <140241684+BlueX888@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:44:39 +0800 Subject: [PATCH 3/4] fix(code-interpreter): honor request_timeout=0 in create_code_context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create_code_context` resolved its per-call timeout with `request_timeout or self.connection_config.request_timeout`, so an explicit `request_timeout=0` fell back to the configured default instead of disabling the request deadline (0 is falsy). Resolve it with `ConnectionConfig.get_request_timeout()` instead, which applies the `0 -> None` precedence used everywhere else in the SDK. Async twin likewise. tests/test_execute_timeout.py pins the resolution with parametrized cases: 0 disables the deadline, an explicit value wins, and None keeps the configured default — asserted against the deadline the transport derives, sync and async. --- .../code_interpreter_async.py | 3 +- .../code_interpreter_sync.py | 3 +- .../tests/test_execute_timeout.py | 40 ++++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) 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 cff0bb1c0a..4d92458810 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 @@ -294,7 +294,8 @@ async def create_code_context( headers=headers, json=data, timeout=httpx.Timeout( - request_timeout or self.connection_config.request_timeout, write=0 + self.connection_config.get_request_timeout(request_timeout), + write=0, ), ) 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 04098b9631..f50b14da57 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 @@ -291,7 +291,8 @@ def create_code_context( json=data, headers=headers, timeout=httpx.Timeout( - request_timeout or self.connection_config.request_timeout, write=0 + self.connection_config.get_request_timeout(request_timeout), + write=0, ), ) diff --git a/packages/code-interpreter-python/tests/test_execute_timeout.py b/packages/code-interpreter-python/tests/test_execute_timeout.py index 550a1908df..e2123eb2a8 100644 --- a/packages/code-interpreter-python/tests/test_execute_timeout.py +++ b/packages/code-interpreter-python/tests/test_execute_timeout.py @@ -39,12 +39,16 @@ def stream(self, *args, **kwargs): self._captured.update(kwargs) raise _Stop + def post(self, *args, **kwargs): + self._captured.update(kwargs) + raise _Stop + def _sandbox(cls, captured=None, connection_config=None, url="http://127.0.0.1:9"): class _Fake(cls): @property def connection_config(self): - return ConnectionConfig( + return connection_config or ConnectionConfig( api_key="x", domain="e2b.app", request_timeout=REQUEST_TIMEOUT ) @@ -234,3 +238,37 @@ async def test_async_context_request_is_aborted_after_request_timeout_seconds(): elapsed = time.monotonic() - started assert elapsed < 1.5, f"list_code_contexts() was aborted after {elapsed:.2f}s" + + +@pytest.mark.parametrize( + "request_timeout, expected_read", + [(0, None), (7, 7), (None, REQUEST_TIMEOUT)], +) +def test_context_request_timeout_resolution(request_timeout, expected_read): + captured: dict = {} + with pytest.raises(_Stop): + _sandbox(Sandbox, captured).create_code_context(request_timeout=request_timeout) + + tmo = _captured_timeout(captured) + # An explicit `request_timeout=0` disables the deadline — it must not + # fall back to the configured default — while `None` keeps the default. + assert tmo.read == expected_read + assert tmo.write == 0 + assert _derived_deadline(tmo) == expected_read + + +@pytest.mark.parametrize( + "request_timeout, expected_read", + [(0, None), (7, 7), (None, REQUEST_TIMEOUT)], +) +async def test_async_context_request_timeout_resolution(request_timeout, expected_read): + captured: dict = {} + with pytest.raises(_Stop): + await _sandbox(AsyncSandbox, captured).create_code_context( + request_timeout=request_timeout + ) + + tmo = _captured_timeout(captured) + assert tmo.read == expected_read + assert tmo.write == 0 + assert _derived_deadline(tmo) == expected_read From fb3d75217448cacd65bee578b9adb145efc58c31 Mon Sep 17 00:00:00 2001 From: BlueX888 Date: Wed, 14 Oct 2026 21:58:25 +0800 Subject: [PATCH 4/4] refactor(code-interpreter): move the pyqwest write=0 workaround into one helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transport-specific timeout shape — whole budget on read, write kept at zero so the pyqwest deadline equals the requested timeout — was spelled out at ten call sites across the sync and async mirrors. It now lives in _jupyter_timeout, next to the transport knowledge it encodes, and every Jupyter request calls it. Semantics are unchanged: an explicit timeout still derives exactly that deadline, connect still only bounds the connection phase, and a disabled timeout sets no phase at all — now uniformly on every path, verified against the transport's own convert_timeout in the regression tests. --- .../e2b_code_interpreter/_jupyter_timeout.py | 22 +++++++++++++++ .../code_interpreter_async.py | 28 ++++++------------- .../code_interpreter_sync.py | 28 ++++++------------- .../tests/test_execute_timeout.py | 21 ++++++++------ 4 files changed, 51 insertions(+), 48 deletions(-) create mode 100644 packages/code-interpreter-python/e2b_code_interpreter/_jupyter_timeout.py diff --git a/packages/code-interpreter-python/e2b_code_interpreter/_jupyter_timeout.py b/packages/code-interpreter-python/e2b_code_interpreter/_jupyter_timeout.py new file mode 100644 index 0000000000..3d32e7d32c --- /dev/null +++ b/packages/code-interpreter-python/e2b_code_interpreter/_jupyter_timeout.py @@ -0,0 +1,22 @@ +from typing import Optional + +import httpx + + +def _jupyter_timeout( + timeout: Optional[float], *, connect: Optional[float] = None +) -> httpx.Timeout: + """Build the `httpx.Timeout` every Jupyter request derives its deadline from. + + The SDK's pyqwest-backed transport has no per-phase timeouts: it collapses + them into a single whole-request deadline, derived as the sum of the + `read` and `write` phases (`connect` and `pool` are ignored). The whole + budget therefore goes on `read` and `write` stays at zero, which makes the + deadline `timeout` and not `2 * timeout`. This matches the JS SDK, which + aborts the execution on a `timeout`-long timer. A `None` timeout disables + the deadline entirely and sets no phase at all — the transport still bounds + connect on its own. + """ + if timeout is None: + return httpx.Timeout(None) + return httpx.Timeout(timeout, write=0, connect=connect) 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 4d92458810..bf76265e1a 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 @@ -10,6 +10,7 @@ ) from e2b.api.client_async import get_transport +from e2b_code_interpreter._jupyter_timeout import _jupyter_timeout from e2b_code_interpreter.constants import ( DEFAULT_TEMPLATE, JUPYTER_PORT, @@ -217,20 +218,8 @@ async def run_code( }, headers=headers, # `timeout` bounds the execution, `request_timeout` only the - # connect. The SDK's pyqwest-backed transport has no per-phase - # timeouts: it collapses them into a single whole-request - # deadline, derived as the sum of the `read` and `write` phases - # (`connect` and `pool` are ignored). The whole budget therefore - # goes on `read` and `write` stays at zero, which makes the - # deadline `timeout` and not `2 * timeout`. 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. - timeout=( - httpx.Timeout(timeout, write=0, connect=request_timeout) - if timeout is not None - else httpx.Timeout(None) - ), + # connect; `_jupyter_timeout` holds the transport details. + timeout=_jupyter_timeout(timeout, connect=request_timeout), ) as response: err = await aextract_exception(response) if err: @@ -293,9 +282,8 @@ async def create_code_context( f"{self._jupyter_url}/contexts", headers=headers, json=data, - timeout=httpx.Timeout( - self.connection_config.get_request_timeout(request_timeout), - write=0, + timeout=_jupyter_timeout( + self.connection_config.get_request_timeout(request_timeout) ), ) @@ -336,7 +324,7 @@ async def remove_code_context( response = await self._client.delete( f"{self._jupyter_url}/contexts/{context_id}", headers=headers, - timeout=httpx.Timeout(self.connection_config.request_timeout, write=0), + timeout=_jupyter_timeout(self.connection_config.request_timeout), ) err = await aextract_exception(response) @@ -366,7 +354,7 @@ async def list_code_contexts(self) -> List[Context]: response = await self._client.get( f"{self._jupyter_url}/contexts", headers=headers, - timeout=httpx.Timeout(self.connection_config.request_timeout, write=0), + timeout=_jupyter_timeout(self.connection_config.request_timeout), ) err = await aextract_exception(response) @@ -405,7 +393,7 @@ async def restart_code_context( response = await self._client.post( f"{self._jupyter_url}/contexts/{context_id}/restart", headers=headers, - timeout=httpx.Timeout(self.connection_config.request_timeout, write=0), + timeout=_jupyter_timeout(self.connection_config.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 f50b14da57..ef4072e64e 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 @@ -6,6 +6,7 @@ from e2b import Sandbox as BaseSandbox, InvalidArgumentException from e2b.api.client_sync import get_transport +from e2b_code_interpreter._jupyter_timeout import _jupyter_timeout from e2b_code_interpreter.constants import ( DEFAULT_TEMPLATE, JUPYTER_PORT, @@ -214,20 +215,8 @@ def run_code( }, headers=headers, # `timeout` bounds the execution, `request_timeout` only the - # connect. The SDK's pyqwest-backed transport has no per-phase - # timeouts: it collapses them into a single whole-request - # deadline, derived as the sum of the `read` and `write` phases - # (`connect` and `pool` are ignored). The whole budget therefore - # goes on `read` and `write` stays at zero, which makes the - # deadline `timeout` and not `2 * timeout`. 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. - timeout=( - httpx.Timeout(timeout, write=0, connect=request_timeout) - if timeout is not None - else httpx.Timeout(None) - ), + # connect; `_jupyter_timeout` holds the transport details. + timeout=_jupyter_timeout(timeout, connect=request_timeout), ) as response: err = extract_exception(response) if err: @@ -290,9 +279,8 @@ def create_code_context( f"{self._jupyter_url}/contexts", json=data, headers=headers, - timeout=httpx.Timeout( - self.connection_config.get_request_timeout(request_timeout), - write=0, + timeout=_jupyter_timeout( + self.connection_config.get_request_timeout(request_timeout) ), ) @@ -333,7 +321,7 @@ def remove_code_context( response = self._client.delete( f"{self._jupyter_url}/contexts/{context_id}", headers=headers, - timeout=httpx.Timeout(self.connection_config.request_timeout, write=0), + timeout=_jupyter_timeout(self.connection_config.request_timeout), ) err = extract_exception(response) @@ -363,7 +351,7 @@ def list_code_contexts(self) -> List[Context]: response = self._client.get( f"{self._jupyter_url}/contexts", headers=headers, - timeout=httpx.Timeout(self.connection_config.request_timeout, write=0), + timeout=_jupyter_timeout(self.connection_config.request_timeout), ) err = extract_exception(response) @@ -403,7 +391,7 @@ def restart_code_context( response = self._client.post( f"{self._jupyter_url}/contexts/{context_id}/restart", headers=headers, - timeout=httpx.Timeout(self.connection_config.request_timeout, write=0), + timeout=_jupyter_timeout(self.connection_config.request_timeout), ) err = extract_exception(response) diff --git a/packages/code-interpreter-python/tests/test_execute_timeout.py b/packages/code-interpreter-python/tests/test_execute_timeout.py index e2123eb2a8..d6b554a177 100644 --- a/packages/code-interpreter-python/tests/test_execute_timeout.py +++ b/packages/code-interpreter-python/tests/test_execute_timeout.py @@ -241,10 +241,12 @@ async def test_async_context_request_is_aborted_after_request_timeout_seconds(): @pytest.mark.parametrize( - "request_timeout, expected_read", - [(0, None), (7, 7), (None, REQUEST_TIMEOUT)], + "request_timeout, expected_read, expected_write", + [(0, None, None), (7, 7, 0), (None, REQUEST_TIMEOUT, 0)], ) -def test_context_request_timeout_resolution(request_timeout, expected_read): +def test_context_request_timeout_resolution( + request_timeout, expected_read, expected_write +): captured: dict = {} with pytest.raises(_Stop): _sandbox(Sandbox, captured).create_code_context(request_timeout=request_timeout) @@ -252,16 +254,19 @@ def test_context_request_timeout_resolution(request_timeout, expected_read): tmo = _captured_timeout(captured) # An explicit `request_timeout=0` disables the deadline — it must not # fall back to the configured default — while `None` keeps the default. + # A disabled deadline sets no phase at all, matching the execution path. assert tmo.read == expected_read - assert tmo.write == 0 + assert tmo.write == expected_write assert _derived_deadline(tmo) == expected_read @pytest.mark.parametrize( - "request_timeout, expected_read", - [(0, None), (7, 7), (None, REQUEST_TIMEOUT)], + "request_timeout, expected_read, expected_write", + [(0, None, None), (7, 7, 0), (None, REQUEST_TIMEOUT, 0)], ) -async def test_async_context_request_timeout_resolution(request_timeout, expected_read): +async def test_async_context_request_timeout_resolution( + request_timeout, expected_read, expected_write +): captured: dict = {} with pytest.raises(_Stop): await _sandbox(AsyncSandbox, captured).create_code_context( @@ -270,5 +275,5 @@ async def test_async_context_request_timeout_resolution(request_timeout, expecte tmo = _captured_timeout(captured) assert tmo.read == expected_read - assert tmo.write == 0 + assert tmo.write == expected_write assert _derived_deadline(tmo) == expected_read