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-interpreter 2.49.1, pyqwest 0.10.0 (both installed from the pinned lockfile)
- Repo commit:
80496c0f7bee0b0ff8304ef86be11c3aea01ecb6
- Python 3.12,
httpx provided by pyqwest.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 carries timeout, because the transport collapses the per-phase timeouts into one whole-request deadline, and 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".
- The regression test's own rationale (
tests/test_execute_timeout.py:1-7 and :78-79) says: "Every non-connect phase carries timeout, so the deadline the transport derives is timeout and not max(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.
Sandbox ID or Build ID
N/A — reproduced offline against a raw localhost socket serving the Jupyter
/executeendpoint, so no sandbox or API key is involved.Environment
e2b/e2b-code-interpreter2.49.1,pyqwest0.10.0 (both installed from the pinned lockfile)80496c0f7bee0b0ff8304ef86be11c3aea01ecb6httpxprovided bypyqwest.httpxTimestamp 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 roughlynseconds, andtimeout=0should keep the current "no execution deadline" behaviour.The in-tree intent is explicit and is contradicted by the transport that actually runs the request:
code_interpreter_sync.py:152) says:param timeout: Timeout for the code execution in **seconds**.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".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)."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
readandwrite.Actual behavior
Because
httpx.Timeout(timeout, connect=request_timeout)setsread = write = pool = timeout, and the pyqwest transport computes its single operation deadline asread + write(ignoringconnectandpool), the effective execution deadline is2 * 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):Verbatim output, verifier 2 (own repro, same commit):
My own run of
/tmp/e2brepro/repro_timeout_doubled.pyat80496c0f:And the derived deadline for the exact
httpx.Timeoutobjects the SDK builds (viapyqwest.httpx._transport.convert_timeout):timeout=0is unaffected, since it takes thehttpx.Timeout(None)branch andconvert_timeoutreturnsNone.Issue reproduction
The reproduction needs no sandbox: a raw localhost socket speaks just enough HTTP to answer the Jupyter
/executerequest, sends the response headers, then withholds the body for 5s. The sandbox subclass only overrides the connection properties.Steps:
python repro_timeout_doubled.py. Expected~1.0s / ~2.0s / ~3.0s; observed2.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 atcode_interpreter_async.py:246) buildhttpx.Timeout(timeout, connect=request_timeout), which setsread = write = pool = timeout. The transport the SDK actually uses,PyqwestTransport(packages/python-sdk/e2b/_http.py/ the installedpyqwest.httpx._transport), ignores phases and derives one operation deadline inconvert_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, soread + write=2 * timeout.Why the existing test missed it.
tests/test_execute_timeout.pyonly asserts thehttpx.Timeoutattribute 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 passedat80496c0f) while the deadline is 2x. The test's own rationale comments state the deadline istimeout, which is not whatconvert_timeoutreturns.Also affected. The four context methods pass a bare scalar (
create_code_contextatcode_interpreter_sync.py:307;remove_code_context/list_code_contexts/restart_code_contextat:347,:377,:417, with async equivalents), which httpx turns intoread = write = scalar, so their deadline is also2 * 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.pyand every user capping a cell. The path isself._client.stream(...)->get_transport(..., http2=False)->PyqwestTransport.handle_request->convert_timeout(request.extensions).Approach. Construct the
Timeoutso the pair the transport sums equals the requested budget — e.g.httpx.Timeout(connect=request_timeout, read=timeout, write=0, pool=timeout)atcode_interpreter_sync.py:241andcode_interpreter_async.py:246, and scale the four context calls the same way.convert_timeoutignores a0phase, so the derived deadline becomes exactlytimeout(verified:{'connect': 60, 'read': 10, 'write': 0, 'pool': 10}->10.0s); keep thetimeout is None/timeout == 0branch as is. The regression test should assert the value the transport derives (convert_timeout(request.extensions) == timeout) rather than only thehttpx.Timeoutfields. 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.