Skip to content

Commit 240fd5b

Browse files
mishushakovclaude
andcommitted
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>
1 parent efadb49 commit 240fd5b

9 files changed

Lines changed: 203 additions & 2 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 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

js/src/sandbox.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { Sandbox as BaseSandbox, InvalidArgumentError } from 'e2b'
1+
import {
2+
Sandbox as BaseSandbox,
3+
InvalidArgumentError,
4+
SandboxError,
5+
} from 'e2b'
26

37
import {
48
Result,
@@ -11,6 +15,7 @@ import {
1115
import {
1216
formatExecutionTimeoutError,
1317
formatRequestTimeoutError,
18+
isConnectionClosedError,
1419
readLines,
1520
} from './utils'
1621
import { JUPYTER_PORT, DEFAULT_TIMEOUT_MS } from './consts'
@@ -278,6 +283,7 @@ export class Sandbox extends BaseSandbox {
278283

279284
return execution
280285
} catch (error) {
286+
await this.throwIfSandboxKilled(error)
281287
throw formatRequestTimeoutError(error)
282288
}
283289
}
@@ -317,6 +323,7 @@ export class Sandbox extends BaseSandbox {
317323

318324
return await res.json()
319325
} catch (error) {
326+
await this.throwIfSandboxKilled(error)
320327
throw formatRequestTimeoutError(error)
321328
}
322329
}
@@ -353,6 +360,7 @@ export class Sandbox extends BaseSandbox {
353360
throw error
354361
}
355362
} catch (error) {
363+
await this.throwIfSandboxKilled(error)
356364
throw formatRequestTimeoutError(error)
357365
}
358366
}
@@ -388,6 +396,7 @@ export class Sandbox extends BaseSandbox {
388396

389397
return await res.json()
390398
} catch (error) {
399+
await this.throwIfSandboxKilled(error)
391400
throw formatRequestTimeoutError(error)
392401
}
393402
}
@@ -424,7 +433,29 @@ export class Sandbox extends BaseSandbox {
424433
throw error
425434
}
426435
} catch (error) {
436+
await this.throwIfSandboxKilled(error)
427437
throw formatRequestTimeoutError(error)
428438
}
429439
}
440+
441+
/**
442+
* Throws a descriptive `SandboxError` if the connection error was caused
443+
* by the sandbox being killed mid-request. If the sandbox is still running
444+
* (or its state can't be determined), returns so the caller can re-throw
445+
* the original error.
446+
*/
447+
private async throwIfSandboxKilled(error: unknown): Promise<void> {
448+
if (
449+
isConnectionClosedError(error) &&
450+
// If the state check itself fails we can't tell whether the sandbox
451+
// was killed — assume it's running so the caller re-throws the
452+
// original error instead of wrongly claiming the sandbox is gone.
453+
(await this.isRunning().catch(() => true)) === false
454+
) {
455+
throw new SandboxError(
456+
'The sandbox was killed while the request was in progress. This can happen when the sandbox times out or is killed manually. ' +
457+
"You can modify the sandbox timeout by passing 'timeoutMs' when starting the sandbox or calling '.setTimeout' on the sandbox with the desired timeout."
458+
)
459+
}
460+
}
430461
}

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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { expect } from 'vitest'
2+
3+
import { isDebug, sandboxTest, wait } from './setup'
4+
5+
sandboxTest.skipIf(isDebug)(
6+
'runCode throws a descriptive error when the sandbox is killed during execution',
7+
async ({ sandbox }) => {
8+
const execution = sandbox.runCode('import time; time.sleep(60)')
9+
const assertion = expect(execution).rejects.toThrowError(
10+
/sandbox was killed while the request was in progress/
11+
)
12+
13+
await wait(2_000)
14+
await sandbox.kill()
15+
16+
await assertion
17+
}
18+
)

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 _raise_if_sandbox_killed(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._raise_if_sandbox_killed(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._raise_if_sandbox_killed(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._raise_if_sandbox_killed(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._raise_if_sandbox_killed(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._raise_if_sandbox_killed(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 _raise_if_sandbox_killed(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._raise_if_sandbox_killed(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._raise_if_sandbox_killed(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._raise_if_sandbox_killed(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._raise_if_sandbox_killed(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._raise_if_sandbox_killed(err)
383+
raise

python/e2b_code_interpreter/exceptions.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from e2b import TimeoutException
1+
from e2b import SandboxException, TimeoutException
22

33

44
def format_request_timeout_error() -> Exception:
@@ -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 SandboxException(
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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import asyncio
2+
3+
import pytest
4+
5+
from e2b import SandboxException
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+
execution = asyncio.create_task(
14+
async_sandbox.run_code("import time; time.sleep(60)")
15+
)
16+
17+
await asyncio.sleep(2)
18+
await async_sandbox.kill()
19+
20+
with pytest.raises(
21+
SandboxException, match="sandbox was killed while the request was in progress"
22+
):
23+
await execution

python/tests/sync/test_killed.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import threading
2+
3+
import pytest
4+
5+
from e2b import SandboxException
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+
SandboxException,
17+
match="sandbox was killed while the request was in progress",
18+
):
19+
sandbox.run_code("import time; time.sleep(60)")
20+
finally:
21+
timer.cancel()

0 commit comments

Comments
 (0)