Skip to content
Merged
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
36 changes: 30 additions & 6 deletions 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,7 +279,7 @@ export class Sandbox extends BaseSandbox {

return execution
} catch (error) {
throw formatRequestTimeoutError(error)
throw await this.handleRequestError(error)
}
}

Expand Down Expand Up @@ -317,7 +318,7 @@ export class Sandbox extends BaseSandbox {

return await res.json()
} catch (error) {
throw formatRequestTimeoutError(error)
throw await this.handleRequestError(error)
}
}

Expand Down Expand Up @@ -353,7 +354,7 @@ export class Sandbox extends BaseSandbox {
throw error
}
} catch (error) {
throw formatRequestTimeoutError(error)
throw await this.handleRequestError(error)
}
}

Expand Down Expand Up @@ -388,7 +389,7 @@ export class Sandbox extends BaseSandbox {

return await res.json()
} catch (error) {
throw formatRequestTimeoutError(error)
throw await this.handleRequestError(error)
}
}

Expand Down Expand Up @@ -424,7 +425,30 @@ export class Sandbox extends BaseSandbox {
throw error
}
} catch (error) {
throw formatRequestTimeoutError(error)
throw await this.handleRequestError(error)
}
}

/**
* Returns the error to throw for a failed request. If the connection was
* closed because the sandbox was killed mid-request, returns a descriptive
* `TimeoutError`. Otherwise falls back to formatting request timeouts and
* re-throwing the original error.
*/
private async handleRequestError(error: unknown): Promise<unknown> {
if (
isConnectionClosedError(error) &&
// If the state check itself fails we can't tell whether the sandbox
// was killed — assume it's running so we re-throw the original error
// instead of wrongly claiming the sandbox is gone.
(await this.isRunning().catch(() => true)) === false
) {
return 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."
)
}

return formatRequestTimeoutError(error)
}
}
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
30 changes: 30 additions & 0 deletions js/tests/killedSandbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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 }) => {
// Keep the execution firmly in-flight until the kill: the sleep is far
// longer than the kill delay and the execution timeout is pushed well
// beyond the kill + disconnect-detection window, so the only thing that
// ends the request is the sandbox being killed (not a body-timer abort
// or the sleep completing on its own).
const execution = sandbox.runCode('import time; time.sleep(300)', {
timeoutMs: 300_000,
})
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
},
60_000
)
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 _handle_connection_error(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._handle_connection_error(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._handle_connection_error(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._handle_connection_error(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._handle_connection_error(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._handle_connection_error(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 _handle_connection_error(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._handle_connection_error(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._handle_connection_error(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._handle_connection_error(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._handle_connection_error(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._handle_connection_error(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",
)
27 changes: 27 additions & 0 deletions python/tests/async/test_async_killed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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,
):
# Keep the execution firmly in-flight until the kill: the sleep is far
# longer than the kill delay and the execution timeout is well beyond the
# kill + disconnect-detection window, so the only thing that ends the
# request is the sandbox being killed.
execution = asyncio.create_task(
async_sandbox.run_code("import time; time.sleep(300)", timeout=300)
)

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

with pytest.raises(
TimeoutException, match="sandbox was killed while the request was in progress"
):
await execution
25 changes: 25 additions & 0 deletions python/tests/sync/test_killed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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",
):
# Keep the execution firmly in-flight until the kill: the sleep is
# far longer than the kill delay and the execution timeout is well
# beyond the kill + disconnect-detection window, so the only thing
# that ends the request is the sandbox being killed.
sandbox.run_code("import time; time.sleep(300)", timeout=300)
finally:
timer.cancel()
Loading