[Bug]: run_code(timeout=N) is enforced at ~2*N seconds — the pyqwest transport derives one deadline by summing read+write
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 13.9k
- Forks
- 1k
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 70
Description
Sandbox ID or Build ID
N/A — reproduced offline against a raw localhost socket serving the Jupyter /execute endpoint, so no sandbox or API key is involved.
Environment
e2b/e2b-code-interpreter2.49.1,pyqwest0.10.0 (both installed from the pinned lockfile)- Repo commit:
80496c0f7bee0b0ff8304ef86be11c3aea01ecb6 - Python 3.12,
httpxprovided bypyqwest.httpx - OS: macOS (Darwin 25.6.0)
Timestamp of the issue
2026-09-17 16:28 UTC (last reproduction)
Frequency
Happens every time
Expected behavior
run_code(code, timeout=n) should abort the execution after roughly n seconds, and timeout=0 should keep the current "no execution deadline" behaviour.
The in-tree intent is explicit and is contradicted by the transport that actually runs the request:
- The public docstring (
code_interpreter_sync.py:152) says:param timeout: Timeout for the code execution in **seconds**. - The comment immediately above the call (
code_interpreter_sync.py:225-239) states the goal is that every non-connect phase carriestimeout, because the transport collapses the per-phase timeouts into one whole-request deadline, and leavingrequest_timeouton the write and pool phases "would raise the floor tomax(timeout, request_timeout)and silently ignore anytimeoutshorter than it". - The regression test's own rationale (
tests/test_execute_timeout.py:1-7and:78-79) says: "Every non-connect phase carriestimeout, so the deadline the transport derives istimeoutand notmax(timeout, request_timeout)." - The JS sibling aborts on an exact
timeout-long timer (packages/code-interpreter-js/src/sandbox.ts:264-268).
The transport does not derive the longest phase, nor the requested value — it derives the sum of read and write.
Actual behavior
Because httpx.Timeout(timeout, connect=request_timeout) sets read = write = pool = timeout, and the pyqwest transport computes its single operation deadline as read + write (ignoring connect and pool), the effective execution deadline is 2 * timeout. A user asking for a 1s cap gets a 2s cap; a 3s cap is not applied at all until 6s.
Verbatim output, verifier 1 (repro_timeout_doubled.py, stall server withholding the response body):
[transport] extensions['timeout']={'connect': 60, 'read': 1, 'write': 1, 'pool': 1} / convert_timeout(...)=2.0
mode=stall stall=5.0s request_timeout=60 timeout=1 -> TimeoutException after 2.01s
[transport] extensions['timeout']={'connect': 60, 'read': 2, 'write': 2, 'pool': 2} / convert_timeout(...)=4.0
mode=stall stall=5.0s request_timeout=60 timeout=2 -> TimeoutException after 4.00s
mode=stall stall=5.0s request_timeout=60 timeout=3 -> COMPLETED after 5.01s
mode=stall stall=5.0s request_timeout=3 timeout=2 -> TimeoutException after 4.00s (sum, not max(2,3)=3)
mode=chunks stall=6s ticks every 0.3s timeout=2 -> TimeoutException after 4.00s (total deadline, not idle)
Verbatim output, verifier 2 (own repro, same commit):
MY repro: timeout=2 stall=6.0 -> TimeoutException: Execution timed out ... after 4.03s (expected ~2s)
MY repro: timeout=4 stall=6.0 -> completed after 6.11s (expected ~4s)
# their script, run as-is: run_code(timeout=1) aborted after 2.03s; timeout=2 after 4.00s; timeout=3 completed after 5.11s
# derived deadline via the existing test harness at HEAD: timeout=3 -> 6.0 WRONG; 10 -> 20.0; 300 -> 600.0 (sync AND async)
# scalar request_timeout path: httpx.Timeout(60.0) -> 120.0 (the four context methods)
# timeout=0 -> None (unaffected)
My own run of /tmp/e2brepro/repro_timeout_doubled.py at 80496c0f:
run_code(timeout=1), server stalls 5.0s: aborted: TimeoutException after 2.02s
run_code(timeout=2), server stalls 5.0s: aborted: TimeoutException after 4.00s
run_code(timeout=3), server stalls 5.0s: completed after 5.11s
pyqwest derived deadline for httpx.Timeout(2, connect=60): 4.0 s
And the derived deadline for the exact httpx.Timeout objects the SDK builds (via pyqwest.httpx._transport.convert_timeout):
{'connect': 60, 'read': 1, 'write': 1, 'pool': 1} -> deadline 2.0
{'connect': 60, 'read': 2, 'write': 2, 'pool': 2} -> deadline 4.0
{'connect': 60, 'read': 3, 'write': 3, 'pool': 3} -> deadline 6.0
scalar httpx.Timeout(60.0) -> 120.0
None -> None
timeout=0 is unaffected, since it takes the httpx.Timeout(None) branch and convert_timeout returns None.
Issue reproduction
The reproduction needs no sandbox: a raw localhost socket speaks just enough HTTP to answer the Jupyter /execute request, sends the response headers, then withholds the body for 5s. The sandbox subclass only overrides the connection properties.
"""run_code(timeout=N) aborts at ~2*N seconds: the pyqwest httpx adapter the SDK
uses derives its single operation deadline by SUMMING read + write, and the Jupyter
client sets both to `timeout`. No network: a raw localhost socket plays the server."""
import socket, threading, time, sys
# point at the checked-out package
sys.path.insert(0, ".../E2B.tree/packages/code-interpreter-python")
import httpx
from pyqwest.httpx._transport import convert_timeout
from e2b.connection_config import ConnectionConfig
from e2b_code_interpreter.code_interpreter_sync import Sandbox
STALL = 5.0
class Server:
def __init__(self):
self.sock = socket.socket(); self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind(("127.0.0.1", 0)); self.sock.listen(1)
self.port = self.sock.getsockname()[1]
threading.Thread(target=self.run, daemon=True).start()
def run(self):
conn, _ = self.sock.accept()
conn.recv(65536)
conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: application/x-ndjson\r\nConnection: close\r\n\r\n")
time.sleep(STALL) # response headers sent, body withheld for STALL seconds
conn.sendall(b'{"type":"result","is_main_result":true,"text":"42"}\n')
time.sleep(0.1); conn.close(); self.sock.close()
class S(Sandbox):
_url = None
@property
def connection_config(self): return ConnectionConfig(api_key="x", domain="e2b.app", request_timeout=60)
@property
def sandbox_id(self): return "sb"
@property
def _envd_access_token(self): return None
@property
def traffic_access_token(self): return None
@property
def _jupyter_url(self): return S._url
def run(timeout):
srv = Server(); S._url = f"http://127.0.0.1:{srv.port}"
t0 = time.time()
try:
S.__new__(S).run_code("x", timeout=timeout); out = "completed"
except Exception as e:
out = f"aborted: {type(e).__name__}"
return time.time() - t0, out
for t in (1, 2, 3):
el, out = run(t)
print(f"run_code(timeout={t}), server stalls {STALL}s: {out} after {el:.2f}s")
print("pyqwest derived deadline for httpx.Timeout(2, connect=60):",
convert_timeout({"timeout": httpx.Timeout(2, connect=60).as_dict()}), "s")
Steps: python repro_timeout_doubled.py. Expected ~1.0s / ~2.0s / ~3.0s; observed 2.02s / 4.00s / completed at 5.11s.
Additional context
Root cause. packages/code-interpreter-python/e2b_code_interpreter/code_interpreter_sync.py:241 (and the async twin at code_interpreter_async.py:246) build httpx.Timeout(timeout, connect=request_timeout), which sets read = write = pool = timeout. The transport the SDK actually uses, PyqwestTransport (packages/python-sdk/e2b/_http.py / the installed pyqwest.httpx._transport), ignores phases and derives one operation deadline in convert_timeout (pyqwest/httpx/_transport.py:378-387): operation_timeout += max(phase_timeout, 0.0) over ("read", "write"). A "write followed by a read" is approximated as the sum, so read + write = 2 * timeout.
Why the existing test missed it. tests/test_execute_timeout.py only asserts the httpx.Timeout attribute values (tmo.read == timeout, tmo.write == timeout, tmo.pool == timeout, tmo.connect == REQUEST_TIMEOUT) and never the deadline the transport derives. The suite passes today (12 passed at 80496c0f) while the deadline is 2x. The test's own rationale comments state the deadline is timeout, which is not what convert_timeout returns.
Also affected. The four context methods pass a bare scalar (create_code_context at code_interpreter_sync.py:307; remove_code_context/list_code_contexts/restart_code_context at :347, :377, :417, with async equivalents), which httpx turns into read = write = scalar, so their deadline is also 2 * request_timeout (e.g. httpx.Timeout(60.0) -> 120.0).
Scope. Any caller of the public API — Sandbox.run_code(code, timeout=...) / await AsyncSandbox.run_code(...), e.g. tests/async/test_async_interrupt.py and every user capping a cell. The path is self._client.stream(...) -> get_transport(..., http2=False) -> PyqwestTransport.handle_request -> convert_timeout(request.extensions).
Approach. Construct the Timeout so the pair the transport sums equals the requested budget — e.g. httpx.Timeout(connect=request_timeout, read=timeout, write=0, pool=timeout) at code_interpreter_sync.py:241 and code_interpreter_async.py:246, and scale the four context calls the same way. convert_timeout ignores a 0 phase, so the derived deadline becomes exactly timeout (verified: {'connect': 60, 'read': 10, 'write': 0, 'pool': 10} -> 10.0s); keep the timeout is None / timeout == 0 branch as is. The regression test should assert the value the transport derives (convert_timeout(request.extensions) == timeout) rather than only the httpx.Timeout fields. Happy to open a PR with this approach.
Related references. Collision checks (issue and PR search for timeout, pyqwest, convert_timeout, "request deadline", and the changed-file history) found no existing report of this. The closest is #1840 (Sandbox.create(timeout=0) sends the 300s default), which is a defaulting bug in a different code path, not the transport deadline.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with code_interpreter_sync.py:241 and code_interpreter_async.py:246, then inspect the context methods and the timeout assertions in tests/test_execute_timeout.py. Trace request.extensions into pyqwest's convert_timeout and verify that run_code and the context methods derive the requested deadline, including the timeout=0 behavior; the regression tests should pass for both sync and async paths.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend-api-design, testing-qa
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100