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/6] 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/6] 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/6] 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)") From d40a189ee420532bc8251ba7cece2d979ba14548 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:54:03 +0200 Subject: [PATCH 4/6] Merge sandbox-killed check and request timeout formatting Consolidate the two-line catch handler into a single formatRequestError call that returns the error to throw, matching the main SDK pattern (e2b-dev/E2B#1419). Co-Authored-By: Claude Opus 4.8 (1M context) --- js/src/sandbox.ts | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/js/src/sandbox.ts b/js/src/sandbox.ts index 47deb008..ecd576eb 100644 --- a/js/src/sandbox.ts +++ b/js/src/sandbox.ts @@ -279,8 +279,7 @@ export class Sandbox extends BaseSandbox { return execution } catch (error) { - await this.throwIfSandboxKilled(error) - throw formatRequestTimeoutError(error) + throw await this.formatRequestError(error) } } @@ -319,8 +318,7 @@ export class Sandbox extends BaseSandbox { return await res.json() } catch (error) { - await this.throwIfSandboxKilled(error) - throw formatRequestTimeoutError(error) + throw await this.formatRequestError(error) } } @@ -356,8 +354,7 @@ export class Sandbox extends BaseSandbox { throw error } } catch (error) { - await this.throwIfSandboxKilled(error) - throw formatRequestTimeoutError(error) + throw await this.formatRequestError(error) } } @@ -392,8 +389,7 @@ export class Sandbox extends BaseSandbox { return await res.json() } catch (error) { - await this.throwIfSandboxKilled(error) - throw formatRequestTimeoutError(error) + throw await this.formatRequestError(error) } } @@ -429,29 +425,30 @@ export class Sandbox extends BaseSandbox { throw error } } catch (error) { - await this.throwIfSandboxKilled(error) - throw formatRequestTimeoutError(error) + throw await this.formatRequestError(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. + * 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 throwIfSandboxKilled(error: unknown): Promise { + private async formatRequestError(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. + // 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 ) { - throw new TimeoutError( + 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) } } From 1df44317549f7e27e7e2a37c200245946e21f28d Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:01:29 +0200 Subject: [PATCH 5/6] Rename error handlers to match main SDK conventions Align with e2b-dev/E2B#1419, which names the health-check-aware error wrappers handle*Error / handle_*_exception: - JS: formatRequestError -> handleRequestError - Python: _raise_if_sandbox_killed -> _handle_connection_error Co-Authored-By: Claude Opus 4.8 (1M context) --- js/src/sandbox.ts | 12 ++++++------ .../e2b_code_interpreter/code_interpreter_async.py | 12 ++++++------ python/e2b_code_interpreter/code_interpreter_sync.py | 12 ++++++------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/js/src/sandbox.ts b/js/src/sandbox.ts index ecd576eb..14aef88e 100644 --- a/js/src/sandbox.ts +++ b/js/src/sandbox.ts @@ -279,7 +279,7 @@ export class Sandbox extends BaseSandbox { return execution } catch (error) { - throw await this.formatRequestError(error) + throw await this.handleRequestError(error) } } @@ -318,7 +318,7 @@ export class Sandbox extends BaseSandbox { return await res.json() } catch (error) { - throw await this.formatRequestError(error) + throw await this.handleRequestError(error) } } @@ -354,7 +354,7 @@ export class Sandbox extends BaseSandbox { throw error } } catch (error) { - throw await this.formatRequestError(error) + throw await this.handleRequestError(error) } } @@ -389,7 +389,7 @@ export class Sandbox extends BaseSandbox { return await res.json() } catch (error) { - throw await this.formatRequestError(error) + throw await this.handleRequestError(error) } } @@ -425,7 +425,7 @@ export class Sandbox extends BaseSandbox { throw error } } catch (error) { - throw await this.formatRequestError(error) + throw await this.handleRequestError(error) } } @@ -435,7 +435,7 @@ export class Sandbox extends BaseSandbox { * `TimeoutError`. Otherwise falls back to formatting request timeouts and * re-throwing the original error. */ - private async formatRequestError(error: unknown): Promise { + private async handleRequestError(error: unknown): Promise { if ( isConnectionClosedError(error) && // If the state check itself fails we can't tell whether the sandbox diff --git a/python/e2b_code_interpreter/code_interpreter_async.py b/python/e2b_code_interpreter/code_interpreter_async.py index eb646397..fcc2737a 100644 --- a/python/e2b_code_interpreter/code_interpreter_async.py +++ b/python/e2b_code_interpreter/code_interpreter_async.py @@ -84,7 +84,7 @@ def _client(self) -> AsyncClient: transport=get_transport(self.connection_config, http2=False), ) - async def _raise_if_sandbox_killed(self, err: Exception) -> None: + 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 @@ -236,7 +236,7 @@ async def run_code( except httpx.TimeoutException: raise format_request_timeout_error() except (httpx.ReadError, httpx.RemoteProtocolError) as err: - await self._raise_if_sandbox_killed(err) + await self._handle_connection_error(err) raise async def create_code_context( @@ -285,7 +285,7 @@ async def create_code_context( except httpx.TimeoutException: raise format_request_timeout_error() except (httpx.ReadError, httpx.RemoteProtocolError) as err: - await self._raise_if_sandbox_killed(err) + await self._handle_connection_error(err) raise async def remove_code_context( @@ -320,7 +320,7 @@ async def remove_code_context( except httpx.TimeoutException: raise format_request_timeout_error() except (httpx.ReadError, httpx.RemoteProtocolError) as err: - await self._raise_if_sandbox_killed(err) + await self._handle_connection_error(err) raise async def list_code_contexts(self) -> List[Context]: @@ -351,7 +351,7 @@ async def list_code_contexts(self) -> List[Context]: except httpx.TimeoutException: raise format_request_timeout_error() except (httpx.ReadError, httpx.RemoteProtocolError) as err: - await self._raise_if_sandbox_killed(err) + await self._handle_connection_error(err) raise async def restart_code_context( @@ -385,5 +385,5 @@ async def restart_code_context( except httpx.TimeoutException: raise format_request_timeout_error() except (httpx.ReadError, httpx.RemoteProtocolError) as err: - await self._raise_if_sandbox_killed(err) + await self._handle_connection_error(err) raise diff --git a/python/e2b_code_interpreter/code_interpreter_sync.py b/python/e2b_code_interpreter/code_interpreter_sync.py index 8fb32ea0..35850e56 100644 --- a/python/e2b_code_interpreter/code_interpreter_sync.py +++ b/python/e2b_code_interpreter/code_interpreter_sync.py @@ -78,7 +78,7 @@ 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: + 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 @@ -229,7 +229,7 @@ def run_code( except httpx.TimeoutException: raise format_request_timeout_error() except (httpx.ReadError, httpx.RemoteProtocolError) as err: - self._raise_if_sandbox_killed(err) + self._handle_connection_error(err) raise def create_code_context( @@ -278,7 +278,7 @@ def create_code_context( except httpx.TimeoutException: raise format_request_timeout_error() except (httpx.ReadError, httpx.RemoteProtocolError) as err: - self._raise_if_sandbox_killed(err) + self._handle_connection_error(err) raise def remove_code_context( @@ -313,7 +313,7 @@ def remove_code_context( except httpx.TimeoutException: raise format_request_timeout_error() except (httpx.ReadError, httpx.RemoteProtocolError) as err: - self._raise_if_sandbox_killed(err) + self._handle_connection_error(err) raise def list_code_contexts(self) -> List[Context]: @@ -344,7 +344,7 @@ def list_code_contexts(self) -> List[Context]: except httpx.TimeoutException: raise format_request_timeout_error() except (httpx.ReadError, httpx.RemoteProtocolError) as err: - self._raise_if_sandbox_killed(err) + self._handle_connection_error(err) raise def restart_code_context( @@ -379,5 +379,5 @@ def restart_code_context( except httpx.TimeoutException: raise format_request_timeout_error() except (httpx.ReadError, httpx.RemoteProtocolError) as err: - self._raise_if_sandbox_killed(err) + self._handle_connection_error(err) raise From bcaab38819eedc2ee8bf820903b4d969d2621620 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:50:36 +0200 Subject: [PATCH 6/6] Keep killed-sandbox tests in-flight until the kill The kill-during-execution tests used time.sleep(60), which in JS matched the default execution timeout (DEFAULT_TIMEOUT_MS = 60s). A slow kill could let the body-timer abort (or the sleep completing) end the request instead of the connection reset, masking the sandbox-killed path the test asserts. Bump the sleep to 300s and set an explicit execution timeout well beyond the kill + disconnect-detection window so the sandbox kill is the only thing that ends the request, matching the interrupt test's convention. Add a 60s vitest timeout to the JS test for the disconnect-detection window. Co-Authored-By: Claude Opus 4.8 (1M context) --- js/tests/killedSandbox.test.ts | 12 ++++++++++-- python/tests/async/test_async_killed.py | 6 +++++- python/tests/sync/test_killed.py | 6 +++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/js/tests/killedSandbox.test.ts b/js/tests/killedSandbox.test.ts index 8eea9398..93753dc3 100644 --- a/js/tests/killedSandbox.test.ts +++ b/js/tests/killedSandbox.test.ts @@ -6,7 +6,14 @@ 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)') + // 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/ @@ -18,5 +25,6 @@ sandboxTest.skipIf(isDebug)( await sandbox.kill() await assertion - } + }, + 60_000 ) diff --git a/python/tests/async/test_async_killed.py b/python/tests/async/test_async_killed.py index 3bbc344f..09cb92fa 100644 --- a/python/tests/async/test_async_killed.py +++ b/python/tests/async/test_async_killed.py @@ -10,8 +10,12 @@ 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(60)") + async_sandbox.run_code("import time; time.sleep(300)", timeout=300) ) await asyncio.sleep(2) diff --git a/python/tests/sync/test_killed.py b/python/tests/sync/test_killed.py index 23f577e7..54bebee5 100644 --- a/python/tests/sync/test_killed.py +++ b/python/tests/sync/test_killed.py @@ -16,6 +16,10 @@ def test_run_code_raises_when_sandbox_is_killed_during_execution(sandbox: Sandbo TimeoutException, match="sandbox was killed while the request was in progress", ): - sandbox.run_code("import time; time.sleep(60)") + # 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()