Skip to content

Commit fe3e5db

Browse files
mishushakovclaude
andauthored
Throw descriptive error when sandbox is killed mid-request (#291)
* 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 <noreply@anthropic.com> * Fix Prettier formatting of e2b import Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent efadb49 commit fe3e5db

9 files changed

Lines changed: 220 additions & 6 deletions

File tree

.changeset/grumpy-sloths-relax.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@e2b/code-interpreter': patch
3+
'@e2b/code-interpreter-python': patch
4+
---
5+
6+
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

js/src/sandbox.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Sandbox as BaseSandbox, InvalidArgumentError } from 'e2b'
1+
import { Sandbox as BaseSandbox, InvalidArgumentError, TimeoutError } from 'e2b'
22

33
import {
44
Result,
@@ -11,6 +11,7 @@ import {
1111
import {
1212
formatExecutionTimeoutError,
1313
formatRequestTimeoutError,
14+
isConnectionClosedError,
1415
readLines,
1516
} from './utils'
1617
import { JUPYTER_PORT, DEFAULT_TIMEOUT_MS } from './consts'
@@ -278,7 +279,7 @@ export class Sandbox extends BaseSandbox {
278279

279280
return execution
280281
} catch (error) {
281-
throw formatRequestTimeoutError(error)
282+
throw await this.handleRequestError(error)
282283
}
283284
}
284285

@@ -317,7 +318,7 @@ export class Sandbox extends BaseSandbox {
317318

318319
return await res.json()
319320
} catch (error) {
320-
throw formatRequestTimeoutError(error)
321+
throw await this.handleRequestError(error)
321322
}
322323
}
323324

@@ -353,7 +354,7 @@ export class Sandbox extends BaseSandbox {
353354
throw error
354355
}
355356
} catch (error) {
356-
throw formatRequestTimeoutError(error)
357+
throw await this.handleRequestError(error)
357358
}
358359
}
359360

@@ -388,7 +389,7 @@ export class Sandbox extends BaseSandbox {
388389

389390
return await res.json()
390391
} catch (error) {
391-
throw formatRequestTimeoutError(error)
392+
throw await this.handleRequestError(error)
392393
}
393394
}
394395

@@ -424,7 +425,30 @@ export class Sandbox extends BaseSandbox {
424425
throw error
425426
}
426427
} catch (error) {
427-
throw formatRequestTimeoutError(error)
428+
throw await this.handleRequestError(error)
428429
}
429430
}
431+
432+
/**
433+
* Returns the error to throw for a failed request. If the connection was
434+
* closed because the sandbox was killed mid-request, returns a descriptive
435+
* `TimeoutError`. Otherwise falls back to formatting request timeouts and
436+
* re-throwing the original error.
437+
*/
438+
private async handleRequestError(error: unknown): Promise<unknown> {
439+
if (
440+
isConnectionClosedError(error) &&
441+
// If the state check itself fails we can't tell whether the sandbox
442+
// was killed — assume it's running so we re-throw the original error
443+
// instead of wrongly claiming the sandbox is gone.
444+
(await this.isRunning().catch(() => true)) === false
445+
) {
446+
return new TimeoutError(
447+
'The sandbox was killed while the request was in progress. This can happen when the sandbox times out or is killed manually. ' +
448+
"You can modify the sandbox timeout by passing 'timeoutMs' when starting the sandbox or calling '.setTimeout' on the sandbox with the desired timeout."
449+
)
450+
}
451+
452+
return formatRequestTimeoutError(error)
453+
}
430454
}

js/src/utils.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,35 @@ export function formatExecutionTimeoutError(error: unknown) {
2020
return error
2121
}
2222

23+
const CONNECTION_CLOSED_CODES = ['ECONNRESET', 'EPIPE', 'UND_ERR_SOCKET']
24+
25+
/**
26+
* Checks if the error means the connection was closed/reset while the request
27+
* was in flight. The shape of this error is runtime-specific — Bun and Deno
28+
* set a `code` directly, while Node's fetch (undici) wraps the socket error
29+
* in the `cause` of a generic `TypeError`.
30+
*/
31+
export function isConnectionClosedError(error: unknown): boolean {
32+
if (!(error instanceof Error)) {
33+
return false
34+
}
35+
36+
const code = (error as { code?: unknown }).code
37+
if (typeof code === 'string' && CONNECTION_CLOSED_CODES.includes(code)) {
38+
return true
39+
}
40+
41+
if (error.name === 'ConnectionReset' || error.name === 'ConnectionClosed') {
42+
return true
43+
}
44+
45+
if (error.cause) {
46+
return isConnectionClosedError(error.cause)
47+
}
48+
49+
return false
50+
}
51+
2352
export async function* readLines(stream: ReadableStream<Uint8Array>) {
2453
const reader = stream.getReader()
2554
let buffer = ''

js/tests/killedSandbox.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { TimeoutError } from 'e2b'
2+
import { expect } from 'vitest'
3+
4+
import { isDebug, sandboxTest, wait } from './setup'
5+
6+
sandboxTest.skipIf(isDebug)(
7+
'runCode throws a descriptive error when the sandbox is killed during execution',
8+
async ({ sandbox }) => {
9+
// Keep the execution firmly in-flight until the kill: the sleep is far
10+
// longer than the kill delay and the execution timeout is pushed well
11+
// beyond the kill + disconnect-detection window, so the only thing that
12+
// ends the request is the sandbox being killed (not a body-timer abort
13+
// or the sleep completing on its own).
14+
const execution = sandbox.runCode('import time; time.sleep(300)', {
15+
timeoutMs: 300_000,
16+
})
17+
const assertion = Promise.all([
18+
expect(execution).rejects.toThrowError(
19+
/sandbox was killed while the request was in progress/
20+
),
21+
expect(execution).rejects.toBeInstanceOf(TimeoutError),
22+
])
23+
24+
await wait(2_000)
25+
await sandbox.kill()
26+
27+
await assertion
28+
},
29+
60_000
30+
)

python/e2b_code_interpreter/code_interpreter_async.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from e2b_code_interpreter.exceptions import (
3030
format_execution_timeout_error,
3131
format_request_timeout_error,
32+
format_sandbox_killed_error,
3233
)
3334

3435
logger = logging.getLogger(__name__)
@@ -83,6 +84,23 @@ def _client(self) -> AsyncClient:
8384
transport=get_transport(self.connection_config, http2=False),
8485
)
8586

87+
async def _handle_connection_error(self, err: Exception) -> None:
88+
"""
89+
Raises a descriptive exception if the connection error was caused by
90+
the sandbox being killed mid-request. If the sandbox is still running
91+
(or its state can't be determined), returns so the caller can re-raise
92+
the original error.
93+
"""
94+
try:
95+
running = await self.is_running()
96+
except Exception:
97+
# The state check itself failed, so we can't tell whether the
98+
# sandbox was killed — let the caller re-raise the original error
99+
# instead of wrongly claiming the sandbox is gone.
100+
return
101+
if not running:
102+
raise format_sandbox_killed_error() from err
103+
86104
@overload
87105
async def run_code(
88106
self,
@@ -217,6 +235,9 @@ async def run_code(
217235
raise format_execution_timeout_error()
218236
except httpx.TimeoutException:
219237
raise format_request_timeout_error()
238+
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
239+
await self._handle_connection_error(err)
240+
raise
220241

221242
async def create_code_context(
222243
self,
@@ -263,6 +284,9 @@ async def create_code_context(
263284
return Context.from_json(data)
264285
except httpx.TimeoutException:
265286
raise format_request_timeout_error()
287+
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
288+
await self._handle_connection_error(err)
289+
raise
266290

267291
async def remove_code_context(
268292
self,
@@ -295,6 +319,9 @@ async def remove_code_context(
295319
raise err
296320
except httpx.TimeoutException:
297321
raise format_request_timeout_error()
322+
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
323+
await self._handle_connection_error(err)
324+
raise
298325

299326
async def list_code_contexts(self) -> List[Context]:
300327
"""
@@ -323,6 +350,9 @@ async def list_code_contexts(self) -> List[Context]:
323350
return [Context.from_json(context_data) for context_data in data]
324351
except httpx.TimeoutException:
325352
raise format_request_timeout_error()
353+
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
354+
await self._handle_connection_error(err)
355+
raise
326356

327357
async def restart_code_context(
328358
self,
@@ -354,3 +384,6 @@ async def restart_code_context(
354384
raise err
355385
except httpx.TimeoutException:
356386
raise format_request_timeout_error()
387+
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
388+
await self._handle_connection_error(err)
389+
raise

python/e2b_code_interpreter/code_interpreter_sync.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from e2b_code_interpreter.exceptions import (
2626
format_execution_timeout_error,
2727
format_request_timeout_error,
28+
format_sandbox_killed_error,
2829
)
2930

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

81+
def _handle_connection_error(self, err: Exception) -> None:
82+
"""
83+
Raises a descriptive exception if the connection error was caused by
84+
the sandbox being killed mid-request. If the sandbox is still running
85+
(or its state can't be determined), returns so the caller can re-raise
86+
the original error.
87+
"""
88+
try:
89+
running = self.is_running()
90+
except Exception:
91+
# The state check itself failed, so we can't tell whether the
92+
# sandbox was killed — let the caller re-raise the original error
93+
# instead of wrongly claiming the sandbox is gone.
94+
return
95+
if not running:
96+
raise format_sandbox_killed_error() from err
97+
8098
@overload
8199
def run_code(
82100
self,
@@ -210,6 +228,9 @@ def run_code(
210228
raise format_execution_timeout_error()
211229
except httpx.TimeoutException:
212230
raise format_request_timeout_error()
231+
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
232+
self._handle_connection_error(err)
233+
raise
213234

214235
def create_code_context(
215236
self,
@@ -256,6 +277,9 @@ def create_code_context(
256277
return Context.from_json(data)
257278
except httpx.TimeoutException:
258279
raise format_request_timeout_error()
280+
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
281+
self._handle_connection_error(err)
282+
raise
259283

260284
def remove_code_context(
261285
self,
@@ -288,6 +312,9 @@ def remove_code_context(
288312
raise err
289313
except httpx.TimeoutException:
290314
raise format_request_timeout_error()
315+
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
316+
self._handle_connection_error(err)
317+
raise
291318

292319
def list_code_contexts(self) -> List[Context]:
293320
"""
@@ -316,6 +343,9 @@ def list_code_contexts(self) -> List[Context]:
316343
return [Context.from_json(context_data) for context_data in data]
317344
except httpx.TimeoutException:
318345
raise format_request_timeout_error()
346+
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
347+
self._handle_connection_error(err)
348+
raise
319349

320350
def restart_code_context(
321351
self,
@@ -348,3 +378,6 @@ def restart_code_context(
348378
raise err
349379
except httpx.TimeoutException:
350380
raise format_request_timeout_error()
381+
except (httpx.ReadError, httpx.RemoteProtocolError) as err:
382+
self._handle_connection_error(err)
383+
raise

python/e2b_code_interpreter/exceptions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,10 @@ def format_execution_timeout_error() -> Exception:
1111
return TimeoutException(
1212
"Execution timed out — the 'timeout' option can be used to increase this timeout",
1313
)
14+
15+
16+
def format_sandbox_killed_error() -> Exception:
17+
return TimeoutException(
18+
"The sandbox was killed while the request was in progress. This can happen when the sandbox times out or is killed manually. "
19+
"You can modify the sandbox timeout by passing 'timeout' when starting the sandbox or calling '.set_timeout' on the sandbox with the desired timeout",
20+
)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import asyncio
2+
3+
import pytest
4+
5+
from e2b import TimeoutException
6+
from e2b_code_interpreter import AsyncSandbox
7+
8+
9+
@pytest.mark.skip_debug
10+
async def test_run_code_raises_when_sandbox_is_killed_during_execution(
11+
async_sandbox: AsyncSandbox,
12+
):
13+
# Keep the execution firmly in-flight until the kill: the sleep is far
14+
# longer than the kill delay and the execution timeout is well beyond the
15+
# kill + disconnect-detection window, so the only thing that ends the
16+
# request is the sandbox being killed.
17+
execution = asyncio.create_task(
18+
async_sandbox.run_code("import time; time.sleep(300)", timeout=300)
19+
)
20+
21+
await asyncio.sleep(2)
22+
await async_sandbox.kill()
23+
24+
with pytest.raises(
25+
TimeoutException, match="sandbox was killed while the request was in progress"
26+
):
27+
await execution

python/tests/sync/test_killed.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import threading
2+
3+
import pytest
4+
5+
from e2b import TimeoutException
6+
from e2b_code_interpreter import Sandbox
7+
8+
9+
@pytest.mark.skip_debug
10+
def test_run_code_raises_when_sandbox_is_killed_during_execution(sandbox: Sandbox):
11+
timer = threading.Timer(2.0, sandbox.kill)
12+
timer.start()
13+
14+
try:
15+
with pytest.raises(
16+
TimeoutException,
17+
match="sandbox was killed while the request was in progress",
18+
):
19+
# Keep the execution firmly in-flight until the kill: the sleep is
20+
# far longer than the kill delay and the execution timeout is well
21+
# beyond the kill + disconnect-detection window, so the only thing
22+
# that ends the request is the sandbox being killed.
23+
sandbox.run_code("import time; time.sleep(300)", timeout=300)
24+
finally:
25+
timer.cancel()

0 commit comments

Comments
 (0)