diff --git a/.changeset/grumpy-sloths-relax.md b/.changeset/grumpy-sloths-relax.md new file mode 100644 index 00000000..ea5eae37 --- /dev/null +++ b/.changeset/grumpy-sloths-relax.md @@ -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 diff --git a/js/src/sandbox.ts b/js/src/sandbox.ts index 0b320cc6..47deb008 100644 --- a/js/src/sandbox.ts +++ b/js/src/sandbox.ts @@ -1,4 +1,4 @@ -import { Sandbox as BaseSandbox, InvalidArgumentError } from 'e2b' +import { Sandbox as BaseSandbox, InvalidArgumentError, TimeoutError } from 'e2b' import { Result, @@ -11,6 +11,7 @@ import { import { formatExecutionTimeoutError, formatRequestTimeoutError, + isConnectionClosedError, readLines, } from './utils' import { JUPYTER_PORT, DEFAULT_TIMEOUT_MS } from './consts' @@ -278,6 +279,7 @@ export class Sandbox extends BaseSandbox { return execution } catch (error) { + await this.throwIfSandboxKilled(error) throw formatRequestTimeoutError(error) } } @@ -317,6 +319,7 @@ export class Sandbox extends BaseSandbox { return await res.json() } catch (error) { + await this.throwIfSandboxKilled(error) throw formatRequestTimeoutError(error) } } @@ -353,6 +356,7 @@ export class Sandbox extends BaseSandbox { throw error } } catch (error) { + await this.throwIfSandboxKilled(error) throw formatRequestTimeoutError(error) } } @@ -388,6 +392,7 @@ export class Sandbox extends BaseSandbox { return await res.json() } catch (error) { + await this.throwIfSandboxKilled(error) throw formatRequestTimeoutError(error) } } @@ -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 { + 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." + ) + } + } } 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..8eea9398 --- /dev/null +++ b/js/tests/killedSandbox.test.ts @@ -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)') + 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 + } +) 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..e2f7ad44 100644 --- a/python/e2b_code_interpreter/exceptions.py +++ b/python/e2b_code_interpreter/exceptions.py @@ -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", + ) diff --git a/python/tests/async/test_async_killed.py b/python/tests/async/test_async_killed.py new file mode 100644 index 00000000..3bbc344f --- /dev/null +++ b/python/tests/async/test_async_killed.py @@ -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 diff --git a/python/tests/sync/test_killed.py b/python/tests/sync/test_killed.py new file mode 100644 index 00000000..23f577e7 --- /dev/null +++ b/python/tests/sync/test_killed.py @@ -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()