Skip to content
Open
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/grumpy-sloths-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@e2b/code-interpreter': patch
'@e2b/code-interpreter-python': patch
---

Throw a descriptive `TimeoutError`/`TimeoutException` instead of a raw socket error (e.g. `ECONNRESET`) when the sandbox is killed or times out while a request (`runCode`/`run_code`, context management) is in progress
29 changes: 28 additions & 1 deletion js/src/sandbox.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Sandbox as BaseSandbox, InvalidArgumentError } from 'e2b'
import { Sandbox as BaseSandbox, InvalidArgumentError, TimeoutError } from 'e2b'

import {
Result,
Expand All @@ -11,6 +11,7 @@ import {
import {
formatExecutionTimeoutError,
formatRequestTimeoutError,
isConnectionClosedError,
readLines,
} from './utils'
import { JUPYTER_PORT, DEFAULT_TIMEOUT_MS } from './consts'
Expand Down Expand Up @@ -278,6 +279,7 @@ export class Sandbox extends BaseSandbox {

return execution
} catch (error) {
await this.throwIfSandboxKilled(error)
throw formatRequestTimeoutError(error)
}
}
Expand Down Expand Up @@ -317,6 +319,7 @@ export class Sandbox extends BaseSandbox {

return await res.json()
} catch (error) {
await this.throwIfSandboxKilled(error)
throw formatRequestTimeoutError(error)
}
}
Expand Down Expand Up @@ -353,6 +356,7 @@ export class Sandbox extends BaseSandbox {
throw error
}
} catch (error) {
await this.throwIfSandboxKilled(error)
throw formatRequestTimeoutError(error)
}
}
Expand Down Expand Up @@ -388,6 +392,7 @@ export class Sandbox extends BaseSandbox {

return await res.json()
} catch (error) {
await this.throwIfSandboxKilled(error)
throw formatRequestTimeoutError(error)
}
}
Expand Down Expand Up @@ -424,7 +429,29 @@ export class Sandbox extends BaseSandbox {
throw error
}
} catch (error) {
await this.throwIfSandboxKilled(error)
throw formatRequestTimeoutError(error)
}
}

/**
* Throws a descriptive `TimeoutError` if the connection error was caused
* by the sandbox being killed mid-request. If the sandbox is still running
* (or its state can't be determined), returns so the caller can re-throw
* the original error.
*/
private async throwIfSandboxKilled(error: unknown): Promise<void> {
if (
isConnectionClosedError(error) &&
// If the state check itself fails we can't tell whether the sandbox
// was killed — assume it's running so the caller re-throws the
// original error instead of wrongly claiming the sandbox is gone.
(await this.isRunning().catch(() => true)) === false
) {
throw new TimeoutError(
'The sandbox was killed while the request was in progress. This can happen when the sandbox times out or is killed manually. ' +
"You can modify the sandbox timeout by passing 'timeoutMs' when starting the sandbox or calling '.setTimeout' on the sandbox with the desired timeout."
)
}
}
}
29 changes: 29 additions & 0 deletions js/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,35 @@ export function formatExecutionTimeoutError(error: unknown) {
return error
}

const CONNECTION_CLOSED_CODES = ['ECONNRESET', 'EPIPE', 'UND_ERR_SOCKET']

/**
* Checks if the error means the connection was closed/reset while the request
* was in flight. The shape of this error is runtime-specific — Bun and Deno
* set a `code` directly, while Node's fetch (undici) wraps the socket error
* in the `cause` of a generic `TypeError`.
*/
export function isConnectionClosedError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false
}

const code = (error as { code?: unknown }).code
if (typeof code === 'string' && CONNECTION_CLOSED_CODES.includes(code)) {
return true
}

if (error.name === 'ConnectionReset' || error.name === 'ConnectionClosed') {
return true
}

if (error.cause) {
return isConnectionClosedError(error.cause)
}

return false
}

export async function* readLines(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader()
let buffer = ''
Expand Down
22 changes: 22 additions & 0 deletions js/tests/killedSandbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { TimeoutError } from 'e2b'
import { expect } from 'vitest'

import { isDebug, sandboxTest, wait } from './setup'

sandboxTest.skipIf(isDebug)(
'runCode throws a descriptive error when the sandbox is killed during execution',
async ({ sandbox }) => {
const execution = sandbox.runCode('import time; time.sleep(60)')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kill test sleep too short

Medium Severity

The new kill-during-execution tests use time.sleep(60) while runCode/run_code rely on the default execution timeout (60s in JS, 300s in Python). That ratio is far below the project’s 10×/100× guidance for interrupt-style execution tests, so on JS the run can hit the default timeoutMs abort around the same time as the sleep ends instead of staying in-flight when the sandbox is killed.

Additional Locations (2)
Fix in Cursor Fix in Web

Triggered by learned rule: Test sleep durations must far exceed test timeouts

Reviewed by Cursor Bugbot for commit 17cce6a. Configure here.

const assertion = Promise.all([
expect(execution).rejects.toThrowError(
/sandbox was killed while the request was in progress/
),
expect(execution).rejects.toBeInstanceOf(TimeoutError),
])

await wait(2_000)
await sandbox.kill()

await assertion
}
)
33 changes: 33 additions & 0 deletions python/e2b_code_interpreter/code_interpreter_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from e2b_code_interpreter.exceptions import (
format_execution_timeout_error,
format_request_timeout_error,
format_sandbox_killed_error,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -83,6 +84,23 @@ def _client(self) -> AsyncClient:
transport=get_transport(self.connection_config, http2=False),
)

async def _raise_if_sandbox_killed(self, err: Exception) -> None:
"""
Raises a descriptive exception if the connection error was caused by
the sandbox being killed mid-request. If the sandbox is still running
(or its state can't be determined), returns so the caller can re-raise
the original error.
"""
try:
running = await self.is_running()
except Exception:
# The state check itself failed, so we can't tell whether the
# sandbox was killed — let the caller re-raise the original error
# instead of wrongly claiming the sandbox is gone.
return
if not running:
raise format_sandbox_killed_error() from err

@overload
async def run_code(
self,
Expand Down Expand Up @@ -217,6 +235,9 @@ async def run_code(
raise format_execution_timeout_error()
except httpx.TimeoutException:
raise format_request_timeout_error()
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
await self._raise_if_sandbox_killed(err)
raise

async def create_code_context(
self,
Expand Down Expand Up @@ -263,6 +284,9 @@ async def create_code_context(
return Context.from_json(data)
except httpx.TimeoutException:
raise format_request_timeout_error()
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
await self._raise_if_sandbox_killed(err)
raise

async def remove_code_context(
self,
Expand Down Expand Up @@ -295,6 +319,9 @@ async def remove_code_context(
raise err
except httpx.TimeoutException:
raise format_request_timeout_error()
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
await self._raise_if_sandbox_killed(err)
raise

async def list_code_contexts(self) -> List[Context]:
"""
Expand Down Expand Up @@ -323,6 +350,9 @@ async def list_code_contexts(self) -> List[Context]:
return [Context.from_json(context_data) for context_data in data]
except httpx.TimeoutException:
raise format_request_timeout_error()
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
await self._raise_if_sandbox_killed(err)
raise

async def restart_code_context(
self,
Expand Down Expand Up @@ -354,3 +384,6 @@ async def restart_code_context(
raise err
except httpx.TimeoutException:
raise format_request_timeout_error()
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
await self._raise_if_sandbox_killed(err)
raise
33 changes: 33 additions & 0 deletions python/e2b_code_interpreter/code_interpreter_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from e2b_code_interpreter.exceptions import (
format_execution_timeout_error,
format_request_timeout_error,
format_sandbox_killed_error,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -77,6 +78,23 @@ def _client(self) -> Client:
# cancelled reliably.
return Client(transport=get_transport(self.connection_config, http2=False))

def _raise_if_sandbox_killed(self, err: Exception) -> None:
"""
Raises a descriptive exception if the connection error was caused by
the sandbox being killed mid-request. If the sandbox is still running
(or its state can't be determined), returns so the caller can re-raise
the original error.
"""
try:
running = self.is_running()
except Exception:
# The state check itself failed, so we can't tell whether the
# sandbox was killed — let the caller re-raise the original error
# instead of wrongly claiming the sandbox is gone.
return
if not running:
raise format_sandbox_killed_error() from err

@overload
def run_code(
self,
Expand Down Expand Up @@ -210,6 +228,9 @@ def run_code(
raise format_execution_timeout_error()
except httpx.TimeoutException:
raise format_request_timeout_error()
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
self._raise_if_sandbox_killed(err)
raise

def create_code_context(
self,
Expand Down Expand Up @@ -256,6 +277,9 @@ def create_code_context(
return Context.from_json(data)
except httpx.TimeoutException:
raise format_request_timeout_error()
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
self._raise_if_sandbox_killed(err)
raise

def remove_code_context(
self,
Expand Down Expand Up @@ -288,6 +312,9 @@ def remove_code_context(
raise err
except httpx.TimeoutException:
raise format_request_timeout_error()
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
self._raise_if_sandbox_killed(err)
raise

def list_code_contexts(self) -> List[Context]:
"""
Expand Down Expand Up @@ -316,6 +343,9 @@ def list_code_contexts(self) -> List[Context]:
return [Context.from_json(context_data) for context_data in data]
except httpx.TimeoutException:
raise format_request_timeout_error()
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
self._raise_if_sandbox_killed(err)
raise

def restart_code_context(
self,
Expand Down Expand Up @@ -348,3 +378,6 @@ def restart_code_context(
raise err
except httpx.TimeoutException:
raise format_request_timeout_error()
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
self._raise_if_sandbox_killed(err)
raise
7 changes: 7 additions & 0 deletions python/e2b_code_interpreter/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,10 @@ def format_execution_timeout_error() -> Exception:
return TimeoutException(
"Execution timed out — the 'timeout' option can be used to increase this timeout",
)


def format_sandbox_killed_error() -> Exception:
return TimeoutException(
"The sandbox was killed while the request was in progress. This can happen when the sandbox times out or is killed manually. "
"You can modify the sandbox timeout by passing 'timeout' when starting the sandbox or calling '.set_timeout' on the sandbox with the desired timeout",
)
23 changes: 23 additions & 0 deletions python/tests/async/test_async_killed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import asyncio

import pytest

from e2b import TimeoutException
from e2b_code_interpreter import AsyncSandbox


@pytest.mark.skip_debug
async def test_run_code_raises_when_sandbox_is_killed_during_execution(
async_sandbox: AsyncSandbox,
):
execution = asyncio.create_task(
async_sandbox.run_code("import time; time.sleep(60)")
)

await asyncio.sleep(2)
await async_sandbox.kill()

with pytest.raises(
TimeoutException, match="sandbox was killed while the request was in progress"
):
await execution
21 changes: 21 additions & 0 deletions python/tests/sync/test_killed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import threading

import pytest

from e2b import TimeoutException
from e2b_code_interpreter import Sandbox


@pytest.mark.skip_debug
def test_run_code_raises_when_sandbox_is_killed_during_execution(sandbox: Sandbox):
timer = threading.Timer(2.0, sandbox.kill)
timer.start()

try:
with pytest.raises(
TimeoutException,
match="sandbox was killed while the request was in progress",
):
sandbox.run_code("import time; time.sleep(60)")
finally:
timer.cancel()
Loading