From 240fd5b2a1d6890fc280b1d48af4e724206d84f5 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:07:04 +0200 Subject: [PATCH 1/3] Throw descriptive error when sandbox is killed mid-request When the sandbox is killed or times out while a request to the Jupyter server is in flight (runCode/run_code or context management), the SDKs surfaced a raw socket error (e.g. ECONNRESET). Now they detect the closed connection, confirm the sandbox is gone via its health check, and throw a descriptive SandboxError/SandboxException instead. If the sandbox is still running (or its state can't be determined), the original error propagates unchanged. Co-Authored-By: Claude Fable 5 --- .changeset/grumpy-sloths-relax.md | 6 ++++ js/src/sandbox.ts | 33 ++++++++++++++++++- js/src/utils.ts | 29 ++++++++++++++++ js/tests/killedSandbox.test.ts | 18 ++++++++++ .../code_interpreter_async.py | 33 +++++++++++++++++++ .../code_interpreter_sync.py | 33 +++++++++++++++++++ python/e2b_code_interpreter/exceptions.py | 9 ++++- python/tests/async/test_async_killed.py | 23 +++++++++++++ python/tests/sync/test_killed.py | 21 ++++++++++++ 9 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 .changeset/grumpy-sloths-relax.md create mode 100644 js/tests/killedSandbox.test.ts create mode 100644 python/tests/async/test_async_killed.py create mode 100644 python/tests/sync/test_killed.py diff --git a/.changeset/grumpy-sloths-relax.md b/.changeset/grumpy-sloths-relax.md new file mode 100644 index 00000000..e479a7fd --- /dev/null +++ b/.changeset/grumpy-sloths-relax.md @@ -0,0 +1,6 @@ +--- +'@e2b/code-interpreter': patch +'@e2b/code-interpreter-python': patch +--- + +Throw a descriptive sandbox error 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 diff --git a/js/src/sandbox.ts b/js/src/sandbox.ts index 0b320cc6..ad3d8291 100644 --- a/js/src/sandbox.ts +++ b/js/src/sandbox.ts @@ -1,4 +1,8 @@ -import { Sandbox as BaseSandbox, InvalidArgumentError } from 'e2b' +import { + Sandbox as BaseSandbox, + InvalidArgumentError, + SandboxError, +} from 'e2b' import { Result, @@ -11,6 +15,7 @@ import { import { formatExecutionTimeoutError, formatRequestTimeoutError, + isConnectionClosedError, readLines, } from './utils' import { JUPYTER_PORT, DEFAULT_TIMEOUT_MS } from './consts' @@ -278,6 +283,7 @@ export class Sandbox extends BaseSandbox { return execution } catch (error) { + await this.throwIfSandboxKilled(error) throw formatRequestTimeoutError(error) } } @@ -317,6 +323,7 @@ export class Sandbox extends BaseSandbox { return await res.json() } catch (error) { + await this.throwIfSandboxKilled(error) throw formatRequestTimeoutError(error) } } @@ -353,6 +360,7 @@ export class Sandbox extends BaseSandbox { throw error } } catch (error) { + await this.throwIfSandboxKilled(error) throw formatRequestTimeoutError(error) } } @@ -388,6 +396,7 @@ export class Sandbox extends BaseSandbox { return await res.json() } catch (error) { + await this.throwIfSandboxKilled(error) throw formatRequestTimeoutError(error) } } @@ -424,7 +433,29 @@ export class Sandbox extends BaseSandbox { throw error } } catch (error) { + await this.throwIfSandboxKilled(error) throw formatRequestTimeoutError(error) } } + + /** + * Throws a descriptive `SandboxError` 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 { + 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 SandboxError( + '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." + ) + } + } } diff --git a/js/src/utils.ts b/js/src/utils.ts index 0bf73c3c..060458e4 100644 --- a/js/src/utils.ts +++ b/js/src/utils.ts @@ -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) { const reader = stream.getReader() let buffer = '' diff --git a/js/tests/killedSandbox.test.ts b/js/tests/killedSandbox.test.ts new file mode 100644 index 00000000..dee6dde9 --- /dev/null +++ b/js/tests/killedSandbox.test.ts @@ -0,0 +1,18 @@ +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)') + const assertion = expect(execution).rejects.toThrowError( + /sandbox was killed while the request was in progress/ + ) + + await wait(2_000) + await sandbox.kill() + + await assertion + } +) diff --git a/python/e2b_code_interpreter/code_interpreter_async.py b/python/e2b_code_interpreter/code_interpreter_async.py index 4f3696c4..eb646397 100644 --- a/python/e2b_code_interpreter/code_interpreter_async.py +++ b/python/e2b_code_interpreter/code_interpreter_async.py @@ -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__) @@ -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, @@ -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, @@ -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, @@ -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]: """ @@ -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, @@ -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 diff --git a/python/e2b_code_interpreter/code_interpreter_sync.py b/python/e2b_code_interpreter/code_interpreter_sync.py index bea57db4..8fb32ea0 100644 --- a/python/e2b_code_interpreter/code_interpreter_sync.py +++ b/python/e2b_code_interpreter/code_interpreter_sync.py @@ -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__) @@ -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, @@ -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, @@ -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, @@ -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]: """ @@ -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, @@ -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 diff --git a/python/e2b_code_interpreter/exceptions.py b/python/e2b_code_interpreter/exceptions.py index 61896921..31fd4458 100644 --- a/python/e2b_code_interpreter/exceptions.py +++ b/python/e2b_code_interpreter/exceptions.py @@ -1,4 +1,4 @@ -from e2b import TimeoutException +from e2b import SandboxException, TimeoutException def format_request_timeout_error() -> Exception: @@ -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 SandboxException( + "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", + ) diff --git a/python/tests/async/test_async_killed.py b/python/tests/async/test_async_killed.py new file mode 100644 index 00000000..bbdd6b22 --- /dev/null +++ b/python/tests/async/test_async_killed.py @@ -0,0 +1,23 @@ +import asyncio + +import pytest + +from e2b import SandboxException +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( + SandboxException, match="sandbox was killed while the request was in progress" + ): + await execution diff --git a/python/tests/sync/test_killed.py b/python/tests/sync/test_killed.py new file mode 100644 index 00000000..e6df5b68 --- /dev/null +++ b/python/tests/sync/test_killed.py @@ -0,0 +1,21 @@ +import threading + +import pytest + +from e2b import SandboxException +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( + SandboxException, + match="sandbox was killed while the request was in progress", + ): + sandbox.run_code("import time; time.sleep(60)") + finally: + timer.cancel() From 4c0e50a1e5118041c18861d9175890f33e0d1c37 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:10:40 +0200 Subject: [PATCH 2/3] Fix Prettier formatting of e2b import Co-Authored-By: Claude Fable 5 --- js/src/sandbox.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/js/src/sandbox.ts b/js/src/sandbox.ts index ad3d8291..2084976e 100644 --- a/js/src/sandbox.ts +++ b/js/src/sandbox.ts @@ -1,8 +1,4 @@ -import { - Sandbox as BaseSandbox, - InvalidArgumentError, - SandboxError, -} from 'e2b' +import { Sandbox as BaseSandbox, InvalidArgumentError, SandboxError } from 'e2b' import { Result, From 17cce6a9060b6955c748f61315e90467b1668e21 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:44:23 +0200 Subject: [PATCH 3/3] Use TimeoutError for confirmed sandbox-killed errors Matches the existing 502 mapping in extractError/extract_exception and the base SDK convention: a dead sandbox surfaces as TimeoutError / TimeoutException. When the health probe is inconclusive or the sandbox is still running, the original transport error propagates unchanged. Co-Authored-By: Claude Fable 5 --- .changeset/grumpy-sloths-relax.md | 2 +- js/src/sandbox.ts | 6 +++--- js/tests/killedSandbox.test.ts | 10 +++++++--- python/e2b_code_interpreter/exceptions.py | 4 ++-- python/tests/async/test_async_killed.py | 4 ++-- python/tests/sync/test_killed.py | 4 ++-- 6 files changed, 17 insertions(+), 13 deletions(-) diff --git a/.changeset/grumpy-sloths-relax.md b/.changeset/grumpy-sloths-relax.md index e479a7fd..ea5eae37 100644 --- a/.changeset/grumpy-sloths-relax.md +++ b/.changeset/grumpy-sloths-relax.md @@ -3,4 +3,4 @@ '@e2b/code-interpreter-python': patch --- -Throw a descriptive sandbox error 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 +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 diff --git a/js/src/sandbox.ts b/js/src/sandbox.ts index 2084976e..47deb008 100644 --- a/js/src/sandbox.ts +++ b/js/src/sandbox.ts @@ -1,4 +1,4 @@ -import { Sandbox as BaseSandbox, InvalidArgumentError, SandboxError } from 'e2b' +import { Sandbox as BaseSandbox, InvalidArgumentError, TimeoutError } from 'e2b' import { Result, @@ -435,7 +435,7 @@ export class Sandbox extends BaseSandbox { } /** - * Throws a descriptive `SandboxError` if the connection error was caused + * 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. @@ -448,7 +448,7 @@ export class Sandbox extends BaseSandbox { // original error instead of wrongly claiming the sandbox is gone. (await this.isRunning().catch(() => true)) === false ) { - throw new SandboxError( + 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." ) diff --git a/js/tests/killedSandbox.test.ts b/js/tests/killedSandbox.test.ts index dee6dde9..8eea9398 100644 --- a/js/tests/killedSandbox.test.ts +++ b/js/tests/killedSandbox.test.ts @@ -1,3 +1,4 @@ +import { TimeoutError } from 'e2b' import { expect } from 'vitest' import { isDebug, sandboxTest, wait } from './setup' @@ -6,9 +7,12 @@ 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)') - const assertion = expect(execution).rejects.toThrowError( - /sandbox was killed while the request was in progress/ - ) + 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() diff --git a/python/e2b_code_interpreter/exceptions.py b/python/e2b_code_interpreter/exceptions.py index 31fd4458..e2f7ad44 100644 --- a/python/e2b_code_interpreter/exceptions.py +++ b/python/e2b_code_interpreter/exceptions.py @@ -1,4 +1,4 @@ -from e2b import SandboxException, TimeoutException +from e2b import TimeoutException def format_request_timeout_error() -> Exception: @@ -14,7 +14,7 @@ def format_execution_timeout_error() -> Exception: def format_sandbox_killed_error() -> Exception: - return SandboxException( + 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", ) diff --git a/python/tests/async/test_async_killed.py b/python/tests/async/test_async_killed.py index bbdd6b22..3bbc344f 100644 --- a/python/tests/async/test_async_killed.py +++ b/python/tests/async/test_async_killed.py @@ -2,7 +2,7 @@ import pytest -from e2b import SandboxException +from e2b import TimeoutException from e2b_code_interpreter import AsyncSandbox @@ -18,6 +18,6 @@ async def test_run_code_raises_when_sandbox_is_killed_during_execution( await async_sandbox.kill() with pytest.raises( - SandboxException, match="sandbox was killed while the request was in progress" + TimeoutException, match="sandbox was killed while the request was in progress" ): await execution diff --git a/python/tests/sync/test_killed.py b/python/tests/sync/test_killed.py index e6df5b68..23f577e7 100644 --- a/python/tests/sync/test_killed.py +++ b/python/tests/sync/test_killed.py @@ -2,7 +2,7 @@ import pytest -from e2b import SandboxException +from e2b import TimeoutException from e2b_code_interpreter import Sandbox @@ -13,7 +13,7 @@ def test_run_code_raises_when_sandbox_is_killed_during_execution(sandbox: Sandbo try: with pytest.raises( - SandboxException, + TimeoutException, match="sandbox was killed while the request was in progress", ): sandbox.run_code("import time; time.sleep(60)")