From a2612909e0c61ecb6ae16782ad4d952b572913e7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:49:51 +0000 Subject: [PATCH] feat(sdk): add fork throw-on-error options Co-Authored-By: mish@e2b.dev --- .changeset/calm-forks-raise.md | 6 + packages/js-sdk/src/errors.ts | 24 ++ packages/js-sdk/src/index.ts | 1 + packages/js-sdk/src/sandbox/index.ts | 95 +++++++- packages/js-sdk/src/sandbox/sandboxApi.ts | 9 + .../tests/sandbox/forkThrowOnError.test.ts | 121 ++++++++++ packages/python-sdk/e2b/__init__.py | 2 + packages/python-sdk/e2b/exceptions.py | 24 +- packages/python-sdk/e2b/sandbox_async/main.py | 110 ++++++++- packages/python-sdk/e2b/sandbox_sync/main.py | 110 ++++++++- .../sandbox/test_fork_raise_on_exception.py | 215 ++++++++++++++++++ 11 files changed, 712 insertions(+), 5 deletions(-) create mode 100644 .changeset/calm-forks-raise.md create mode 100644 packages/js-sdk/tests/sandbox/forkThrowOnError.test.ts create mode 100644 packages/python-sdk/tests/shared/sandbox/test_fork_raise_on_exception.py diff --git a/.changeset/calm-forks-raise.md b/.changeset/calm-forks-raise.md new file mode 100644 index 0000000000..4eed45b648 --- /dev/null +++ b/.changeset/calm-forks-raise.md @@ -0,0 +1,6 @@ +--- +"e2b": minor +"@e2b/python-sdk": minor +--- + +Add `throwOnError` (JS) / `raise_on_exception` (Python) to `Sandbox.fork`: throws `SandboxForkError` / `SandboxForkException` with all fork results when any fork failed to start. diff --git a/packages/js-sdk/src/errors.ts b/packages/js-sdk/src/errors.ts index 176fed680f..fe0492d7b8 100644 --- a/packages/js-sdk/src/errors.ts +++ b/packages/js-sdk/src/errors.ts @@ -1,3 +1,5 @@ +import type { Sandbox } from './sandbox' + // Messages of the 502 the proxy in front of envd answers with when the sandbox does // not exist anymore (killed or reached its timeout) or when it is running but envd's // port is not open (e.g. its network is down). @@ -53,6 +55,28 @@ export class SandboxError extends Error { } } +/** + * Thrown by `Sandbox.fork` with `throwOnError: true` when at least one fork failed to start. + * `results` holds one entry per requested fork, in request order — a running sandbox or the error + * that prevented that fork from starting. Successful forks keep running; kill them if you don't need them. + */ +export class SandboxForkError extends SandboxError { + readonly results: Array + + constructor(results: Array) { + const failedErrors = results.filter( + (result): result is Error => result instanceof Error + ) + super( + `${failedErrors.length} of ${results.length} forks failed to start: ${failedErrors + .map((error) => error.message) + .join('; ')}` + ) + this.name = 'SandboxForkError' + this.results = results + } +} + /** * Thrown when a timeout error occurs. * diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 5de45f1226..5d5b6051dc 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -17,6 +17,7 @@ export { NotEnoughSpaceError, NotFoundError, SandboxError, + SandboxForkError, SandboxNotFoundError, SandboxNotRunningError, SandboxUnreachableError, diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index 3af8e5e3d4..9430039192 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -31,7 +31,12 @@ import { } from './sandboxApi' import { getSignature } from './signature' import { compareVersions } from 'compare-versions' -import { InvalidArgumentError, SandboxError, TemplateError } from '../errors' +import { + InvalidArgumentError, + SandboxError, + SandboxForkError, + TemplateError, +} from '../errors' import { ENVD_DEBUG_FALLBACK, ENVD_DEFAULT_USER } from '../envd/versions' import { shellQuote } from '../utils' @@ -412,17 +417,60 @@ export class Sandbox extends SandboxApi { * @param opts fork options — `count`, `timeoutMs` and connection options. * * @returns array with one entry per requested fork — a sandbox instance or an error. + * @throws {SandboxForkError} when `throwOnError` is `true` and at least one fork failed to start. + * + * @example + * ```ts + * const sandbox = await Sandbox.create() + * + * const [fork1, fork2] = await Sandbox.fork(sandbox.sandboxId, { count: 2 }) + * const forks = await Sandbox.fork(sandbox.sandboxId, { count: 2, throwOnError: true }) // Sandbox[] + * if (fork1 instanceof Sandbox) { + * await fork1.commands.run('echo "hello from fork"') + * } + * ``` + */ + static fork( + this: S, + sandboxId: string, + opts: SandboxForkOpts & { throwOnError: true } + ): Promise[]> + /** + * Fork a running sandbox specified by sandbox ID. + * + * The sandbox is checkpointed in place (briefly paused, snapshotted with its + * full memory state, and resumed — its ID and expiration stay untouched) and + * `count` new sandboxes are created from that snapshot. All forks boot from + * the same snapshot, so the snapshot is captured once regardless of count. + * + * Each fork succeeds or fails independently — the returned array contains + * one entry per requested fork, either a running {@link Sandbox} instance or + * an `Error` describing why that fork failed to start + * (`Promise.allSettled`-style). Per-fork error codes map to the same error + * classes as other API errors (e.g. 429 to `RateLimitError`). + * + * @param sandboxId sandbox ID. + * @param opts fork options — `count`, `timeoutMs` and connection options. + * + * @returns array with one entry per requested fork — a sandbox instance or an error. + * @throws {SandboxForkError} when `throwOnError` is `true` and at least one fork failed to start. * * @example * ```ts * const sandbox = await Sandbox.create() * * const [fork1, fork2] = await Sandbox.fork(sandbox.sandboxId, { count: 2 }) + * const forks = await Sandbox.fork(sandbox.sandboxId, { count: 2, throwOnError: true }) // Sandbox[] * if (fork1 instanceof Sandbox) { * await fork1.commands.run('echo "hello from fork"') * } * ``` */ + static fork( + this: S, + sandboxId: string, + opts?: SandboxForkOpts + ): Promise | Error>> static async fork( this: S, sandboxId: string, @@ -438,11 +486,20 @@ export class Sandbox extends SandboxApi { apiOpts ) - return results.map((result) => + const sandboxes = results.map((result) => result instanceof Error ? result : (new this({ ...result, ...config }) as InstanceType) ) + + if ( + apiOpts?.throwOnError && + sandboxes.some((result) => result instanceof Error) + ) { + throw new SandboxForkError(sandboxes) + } + + return sandboxes } /** @@ -492,17 +549,51 @@ export class Sandbox extends SandboxApi { * @param opts fork options — `count`, `timeoutMs` and connection options. * * @returns array with one entry per requested fork — a sandbox instance or an error. + * @throws {SandboxForkError} when `throwOnError` is `true` and at least one fork failed to start. + * + * @example + * ```ts + * const sandbox = await Sandbox.create() + * + * const [fork1, fork2] = await sandbox.fork({ count: 2 }) + * const forks = await sandbox.fork({ count: 2, throwOnError: true }) // Sandbox[] + * if (fork1 instanceof Sandbox) { + * await fork1.commands.run('echo "hello from fork"') + * } + * ``` + */ + fork(opts: SandboxForkOpts & { throwOnError: true }): Promise + /** + * Fork the sandbox. + * + * The sandbox is checkpointed in place (briefly paused, snapshotted with its + * full memory state, and resumed — its ID and expiration stay untouched) and + * `count` new sandboxes are created from that snapshot. All forks boot from + * the same snapshot, so the snapshot is captured once regardless of count. + * + * Each fork succeeds or fails independently — the returned array contains + * one entry per requested fork, either a running {@link Sandbox} instance or + * an `Error` describing why that fork failed to start + * (`Promise.allSettled`-style). Per-fork error codes map to the same error + * classes as other API errors (e.g. 429 to `RateLimitError`). + * + * @param opts fork options — `count`, `timeoutMs` and connection options. + * + * @returns array with one entry per requested fork — a sandbox instance or an error. + * @throws {SandboxForkError} when `throwOnError` is `true` and at least one fork failed to start. * * @example * ```ts * const sandbox = await Sandbox.create() * * const [fork1, fork2] = await sandbox.fork({ count: 2 }) + * const forks = await sandbox.fork({ count: 2, throwOnError: true }) // Sandbox[] * if (fork1 instanceof Sandbox) { * await fork1.commands.run('echo "hello from fork"') * } * ``` */ + fork(opts?: SandboxForkOpts): Promise> async fork(opts?: SandboxForkOpts): Promise> { const cls = this.constructor as typeof Sandbox return (await cls.fork(this.sandboxId, this.resolveApiOpts(opts))) as Array< diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index a7025be4c3..0c61bfe7a9 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -646,6 +646,15 @@ export interface SandboxForkOpts extends ConnectionOpts { */ count?: number + /** + * When `true`, throws `SandboxForkError` with all fork results if any fork + * failed to start. Otherwise, when all forks succeed, resolves to only + * sandbox instances. + * + * @default false + */ + throwOnError?: boolean + /** * Timeout for the forked sandboxes in **milliseconds**. * Maximum time a sandbox can be kept alive is 24 hours (86_400_000 milliseconds) for Pro users and 1 hour (3_600_000 milliseconds) for Hobby users. diff --git a/packages/js-sdk/tests/sandbox/forkThrowOnError.test.ts b/packages/js-sdk/tests/sandbox/forkThrowOnError.test.ts new file mode 100644 index 0000000000..febb007c7f --- /dev/null +++ b/packages/js-sdk/tests/sandbox/forkThrowOnError.test.ts @@ -0,0 +1,121 @@ +import { afterAll, afterEach, beforeAll, expect, test } from 'vitest' +import { http, HttpResponse } from 'msw' + +import { + RateLimitError, + Sandbox, + SandboxError, + SandboxForkError, +} from '../../src' +import { TEST_API_KEY, apiUrl } from '../setup' +import { setupMockApi } from '../mockApi' + +let lastForkBody: Record | undefined + +const server = setupMockApi( + http.post(apiUrl('/v2/sandboxes'), () => + HttpResponse.json({ + sandboxID: 'source-sandbox', + templateID: 'base', + envdVersion: '0.2.4', + }) + ), + http.post(apiUrl('/sandboxes/:sandboxID/fork'), async ({ request }) => { + lastForkBody = (await request.json()) as Record + return HttpResponse.json([ + { + sandbox: { + sandboxID: 'fork-1', + templateID: 'base', + envdVersion: '0.2.4', + }, + }, + { + error: { + code: 429, + message: 'rate limited', + }, + }, + ]) + }) +) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + +afterAll(() => server.close()) + +afterEach(() => { + lastForkBody = undefined + server.resetHandlers() +}) + +test('Sandbox.fork returns the sandbox and error when throwOnError is unset', async () => { + const results = await Sandbox.fork('source-sandbox', { + apiKey: TEST_API_KEY, + }) + + expect(results).toHaveLength(2) + expect(results[0]).toBeInstanceOf(Sandbox) + expect(results[1]).toBeInstanceOf(RateLimitError) +}) + +test('Sandbox.fork throws with all results when throwOnError is true', async () => { + const error = await Sandbox.fork('source-sandbox', { + apiKey: TEST_API_KEY, + throwOnError: true, + }).catch((cause: unknown) => cause) + + expect(error).toBeInstanceOf(SandboxForkError) + expect(error).toBeInstanceOf(SandboxError) + + if (!(error instanceof SandboxForkError)) { + throw error + } + + expect(error.results).toHaveLength(2) + expect(error.results[0]).toBeInstanceOf(Sandbox) + expect((error.results[0] as Sandbox).sandboxId).toBe('fork-1') + expect(error.results[1]).toBeInstanceOf(RateLimitError) + expect(error.message).toContain('1 of 2') + expect(error.message).toContain('rate limited') + expect(lastForkBody).not.toHaveProperty('throwOnError') +}) + +test('instance Sandbox.fork throws SandboxForkError when throwOnError is true', async () => { + const sandbox = await Sandbox.create('base', { apiKey: TEST_API_KEY }) + + await expect(sandbox.fork({ throwOnError: true })).rejects.toBeInstanceOf( + SandboxForkError + ) +}) + +test('Sandbox.fork resolves only sandboxes when all forks succeed', async () => { + server.use( + http.post(apiUrl('/sandboxes/:sandboxID/fork'), () => + HttpResponse.json([ + { + sandbox: { + sandboxID: 'fork-1', + templateID: 'base', + envdVersion: '0.2.4', + }, + }, + { + sandbox: { + sandboxID: 'fork-2', + templateID: 'base', + envdVersion: '0.2.4', + }, + }, + ]) + ) + ) + + const forks = await Sandbox.fork('source-sandbox', { + apiKey: TEST_API_KEY, + throwOnError: true, + }) + + expect(forks).toHaveLength(2) + expect(forks.every((fork) => fork instanceof Sandbox)).toBe(true) +}) diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index 53830c87c2..fd7d8e0bc3 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -50,6 +50,7 @@ RateLimitException, ServiceBusyException, SandboxException, + SandboxForkException, SandboxNotFoundException, SandboxNotRunningException, SandboxUnreachableException, @@ -185,6 +186,7 @@ "Username", # Exceptions "SandboxException", + "SandboxForkException", "ServiceBusyException", "TimeoutException", "NotFoundException", diff --git a/packages/python-sdk/e2b/exceptions.py b/packages/python-sdk/e2b/exceptions.py index d479e21313..4cca834b99 100644 --- a/packages/python-sdk/e2b/exceptions.py +++ b/packages/python-sdk/e2b/exceptions.py @@ -1,4 +1,7 @@ -from typing import Optional +from typing import TYPE_CHECKING, List, Optional, Sequence, Union + +if TYPE_CHECKING: + from .sandbox.main import SandboxBase # Messages of the 502 the proxy in front of envd answers with when the sandbox does @@ -60,6 +63,25 @@ def __init__(self, *args, status_code: Optional[int] = None): self.status_code = status_code +class SandboxForkException(SandboxException): + """ + Raised by `Sandbox.fork` with `raise_on_exception=True` when at least one fork failed to start. + + `results` holds one entry per requested fork, in request order — a running sandbox or the + exception that prevented that fork from starting. Successful forks keep running; kill them if you don't need them. + """ + + results: List[Union["SandboxBase", Exception]] + + def __init__(self, results: Sequence[Union["SandboxBase", Exception]]): + failed_errors = [result for result in results if isinstance(result, Exception)] + super().__init__( + f"{len(failed_errors)} of {len(results)} forks failed to start: " + f"{'; '.join(str(error) for error in failed_errors)}" + ) + self.results = list(results) + + class TimeoutException(SandboxException): """ Raised when a timeout occurs. diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 3d0a648f50..1c6db8b11d 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -4,7 +4,7 @@ import logging import shlex import uuid -from typing import Dict, List, Optional, Union, overload +from typing import Dict, List, Literal, Optional, Union, overload import httpx from packaging.version import Version @@ -17,6 +17,7 @@ from e2b.envd.versions import ENVD_DEBUG_FALLBACK from e2b.exceptions import ( SandboxException, + SandboxForkException, TemplateException, format_request_timeout_error, ) @@ -397,6 +398,8 @@ async def fork( self, timeout: Optional[int] = None, count: Optional[int] = None, + *, + raise_on_exception: Literal[False] = False, **opts: Unpack[ApiParams], ) -> List[Union[Self, Exception]]: """ @@ -416,14 +419,57 @@ async def fork( :param timeout: Timeout for the forked sandboxes in **seconds**. :param count: Number of forked sandboxes to create. An integer between 1 and 20. Omitted, the API default applies. + :param raise_on_exception: Raise `SandboxForkException` with all results if any fork failed to start. :return: List with one entry per requested fork — a sandbox instance or an exception + :raises SandboxForkException: when `raise_on_exception` is `True` and at least one fork failed to start. @example ```python sandbox = await AsyncSandbox.create() fork1, fork2 = await sandbox.fork(count=2) + forks = await sandbox.fork(count=2, raise_on_exception=True) + ``` + """ + ... + + @overload + async def fork( + self, + timeout: Optional[int] = None, + count: Optional[int] = None, + *, + raise_on_exception: Literal[True], + **opts: Unpack[ApiParams], + ) -> List[Self]: + """ + Fork the sandbox. + + The sandbox is checkpointed in place (briefly paused, snapshotted with + its full memory state, and resumed — its ID and expiration stay + untouched) and `count` new sandboxes are created from that snapshot. + All forks boot from the same snapshot, so the snapshot is captured once + regardless of count. + + Each fork succeeds or fails independently — the returned list contains + one entry per requested fork, either a running `AsyncSandbox` instance + or an exception describing why that fork failed to start. Per-fork + error codes map to the same exception classes as other API errors + (e.g. 429 to `RateLimitException`). + + :param timeout: Timeout for the forked sandboxes in **seconds**. + :param count: Number of forked sandboxes to create. An integer between 1 and 20. Omitted, the API default applies. + :param raise_on_exception: Raise `SandboxForkException` with all results if any fork failed to start. + + :return: List of sandbox instances when all forks succeed + :raises SandboxForkException: when `raise_on_exception` is `True` and at least one fork failed to start. + + @example + ```python + sandbox = await AsyncSandbox.create() + + forks = await sandbox.fork(count=2, raise_on_exception=True) ``` """ ... @@ -435,6 +481,8 @@ async def fork( timeout: Optional[int] = None, count: Optional[int] = None, logger: Optional[logging.Logger] = None, + *, + raise_on_exception: Literal[False] = False, **opts: Unpack[ApiParams], ) -> List[Union["AsyncSandbox", Exception]]: """ @@ -456,14 +504,61 @@ async def fork( :param timeout: Timeout for the forked sandboxes in **seconds**. :param count: Number of forked sandboxes to create. An integer between 1 and 20. Omitted, the API default applies. :param logger: Logger used for request and response logging for the forked sandboxes. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. + :param raise_on_exception: Raise `SandboxForkException` with all results if any fork failed to start. :return: List with one entry per requested fork — a sandbox instance or an exception + :raises SandboxForkException: when `raise_on_exception` is `True` and at least one fork failed to start. @example ```python sandbox = await AsyncSandbox.create() fork1, fork2 = await AsyncSandbox.fork(sandbox.sandbox_id, count=2) + forks = await AsyncSandbox.fork(sandbox.sandbox_id, count=2, raise_on_exception=True) + ``` + """ + ... + + @overload + @staticmethod + async def fork( + sandbox_id: str, + timeout: Optional[int] = None, + count: Optional[int] = None, + logger: Optional[logging.Logger] = None, + *, + raise_on_exception: Literal[True], + **opts: Unpack[ApiParams], + ) -> List["AsyncSandbox"]: + """ + Fork a running sandbox specified by sandbox ID. + + The sandbox is checkpointed in place (briefly paused, snapshotted with + its full memory state, and resumed — its ID and expiration stay + untouched) and `count` new sandboxes are created from that snapshot. + All forks boot from the same snapshot, so the snapshot is captured once + regardless of count. + + Each fork succeeds or fails independently — the returned list contains + one entry per requested fork, either a running `AsyncSandbox` instance + or an exception describing why that fork failed to start. Per-fork + error codes map to the same exception classes as other API errors + (e.g. 429 to `RateLimitException`). + + :param sandbox_id: Sandbox ID + :param timeout: Timeout for the forked sandboxes in **seconds**. + :param count: Number of forked sandboxes to create. An integer between 1 and 20. Omitted, the API default applies. + :param logger: Logger used for request and response logging for the forked sandboxes. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. + :param raise_on_exception: Raise `SandboxForkException` with all results if any fork failed to start. + + :return: List of sandbox instances when all forks succeed + :raises SandboxForkException: when `raise_on_exception` is `True` and at least one fork failed to start. + + @example + ```python + sandbox = await AsyncSandbox.create() + + forks = await AsyncSandbox.fork(sandbox.sandbox_id, count=2, raise_on_exception=True) ``` """ ... @@ -473,6 +568,8 @@ async def fork( self, timeout: Optional[int] = None, count: Optional[int] = None, + *, + raise_on_exception: bool = False, **opts: Unpack[ApiParams], ) -> List[Union[Self, Exception]]: """ @@ -492,20 +589,24 @@ async def fork( :param timeout: Timeout for the forked sandboxes in **seconds**. :param count: Number of forked sandboxes to create. An integer between 1 and 20. Omitted, the API default applies. + :param raise_on_exception: Raise `SandboxForkException` with all results if any fork failed to start. :return: List with one entry per requested fork — a sandbox instance or an exception + :raises SandboxForkException: when `raise_on_exception` is `True` and at least one fork failed to start. @example ```python sandbox = await AsyncSandbox.create() fork1, fork2 = await sandbox.fork(count=2) + forks = await sandbox.fork(count=2, raise_on_exception=True) ``` """ return await type(self)._cls_fork_sandbox( self.sandbox_id, timeout=timeout, count=count, + raise_on_exception=raise_on_exception, **self.connection_config.get_api_params(**opts), ) @@ -1129,6 +1230,8 @@ async def _cls_fork_sandbox( timeout: Optional[int] = None, count: Optional[int] = None, logger: Optional[logging.Logger] = None, + *, + raise_on_exception: bool = False, **opts: Unpack[ApiParams], ) -> List[Union[Self, Exception]]: params = cls._resolve_api_params(**opts) @@ -1170,6 +1273,11 @@ async def _cls_fork_sandbox( ) ) + if raise_on_exception and any( + isinstance(sandbox, Exception) for sandbox in sandboxes + ): + raise SandboxForkException(sandboxes) + return sandboxes @classmethod diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 32c1b671b7..7be86db0a2 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -3,7 +3,7 @@ import logging import shlex import uuid -from typing import Dict, List, Optional, Union, overload +from typing import Dict, List, Literal, Optional, Union, overload import httpx from packaging.version import Version @@ -16,6 +16,7 @@ from e2b.envd.versions import ENVD_DEBUG_FALLBACK from e2b.exceptions import ( SandboxException, + SandboxForkException, TemplateException, format_request_timeout_error, ) @@ -392,6 +393,8 @@ def fork( self, timeout: Optional[int] = None, count: Optional[int] = None, + *, + raise_on_exception: Literal[False] = False, **opts: Unpack[ApiParams], ) -> List[Union[Self, Exception]]: """ @@ -411,14 +414,57 @@ def fork( :param timeout: Timeout for the forked sandboxes in **seconds**. :param count: Number of forked sandboxes to create. An integer between 1 and 20. Omitted, the API default applies. + :param raise_on_exception: Raise `SandboxForkException` with all results if any fork failed to start. :return: List with one entry per requested fork — a sandbox instance or an exception + :raises SandboxForkException: when `raise_on_exception` is `True` and at least one fork failed to start. @example ```python sandbox = Sandbox.create() fork1, fork2 = sandbox.fork(count=2) + forks = sandbox.fork(count=2, raise_on_exception=True) + ``` + """ + ... + + @overload + def fork( + self, + timeout: Optional[int] = None, + count: Optional[int] = None, + *, + raise_on_exception: Literal[True], + **opts: Unpack[ApiParams], + ) -> List[Self]: + """ + Fork the sandbox. + + The sandbox is checkpointed in place (briefly paused, snapshotted with + its full memory state, and resumed — its ID and expiration stay + untouched) and `count` new sandboxes are created from that snapshot. + All forks boot from the same snapshot, so the snapshot is captured once + regardless of count. + + Each fork succeeds or fails independently — the returned list contains + one entry per requested fork, either a running `Sandbox` instance + or an exception describing why that fork failed to start. Per-fork + error codes map to the same exception classes as other API errors + (e.g. 429 to `RateLimitException`). + + :param timeout: Timeout for the forked sandboxes in **seconds**. + :param count: Number of forked sandboxes to create. An integer between 1 and 20. Omitted, the API default applies. + :param raise_on_exception: Raise `SandboxForkException` with all results if any fork failed to start. + + :return: List of sandbox instances when all forks succeed + :raises SandboxForkException: when `raise_on_exception` is `True` and at least one fork failed to start. + + @example + ```python + sandbox = Sandbox.create() + + forks = sandbox.fork(count=2, raise_on_exception=True) ``` """ ... @@ -430,6 +476,8 @@ def fork( timeout: Optional[int] = None, count: Optional[int] = None, logger: Optional[logging.Logger] = None, + *, + raise_on_exception: Literal[False] = False, **opts: Unpack[ApiParams], ) -> List[Union["Sandbox", Exception]]: """ @@ -451,14 +499,61 @@ def fork( :param timeout: Timeout for the forked sandboxes in **seconds**. :param count: Number of forked sandboxes to create. An integer between 1 and 20. Omitted, the API default applies. :param logger: Logger used for request and response logging for the forked sandboxes. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. + :param raise_on_exception: Raise `SandboxForkException` with all results if any fork failed to start. :return: List with one entry per requested fork — a sandbox instance or an exception + :raises SandboxForkException: when `raise_on_exception` is `True` and at least one fork failed to start. @example ```python sandbox = Sandbox.create() fork1, fork2 = Sandbox.fork(sandbox.sandbox_id, count=2) + forks = Sandbox.fork(sandbox.sandbox_id, count=2, raise_on_exception=True) + ``` + """ + ... + + @overload + @staticmethod + def fork( + sandbox_id: str, + timeout: Optional[int] = None, + count: Optional[int] = None, + logger: Optional[logging.Logger] = None, + *, + raise_on_exception: Literal[True], + **opts: Unpack[ApiParams], + ) -> List["Sandbox"]: + """ + Fork a running sandbox specified by sandbox ID. + + The sandbox is checkpointed in place (briefly paused, snapshotted with + its full memory state, and resumed — its ID and expiration stay + untouched) and `count` new sandboxes are created from that snapshot. + All forks boot from the same snapshot, so the snapshot is captured once + regardless of count. + + Each fork succeeds or fails independently — the returned list contains + one entry per requested fork, either a running `Sandbox` instance + or an exception describing why that fork failed to start. Per-fork + error codes map to the same exception classes as other API errors + (e.g. 429 to `RateLimitException`). + + :param sandbox_id: Sandbox ID + :param timeout: Timeout for the forked sandboxes in **seconds**. + :param count: Number of forked sandboxes to create. An integer between 1 and 20. Omitted, the API default applies. + :param logger: Logger used for request and response logging for the forked sandboxes. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. + :param raise_on_exception: Raise `SandboxForkException` with all results if any fork failed to start. + + :return: List of sandbox instances when all forks succeed + :raises SandboxForkException: when `raise_on_exception` is `True` and at least one fork failed to start. + + @example + ```python + sandbox = Sandbox.create() + + forks = Sandbox.fork(sandbox.sandbox_id, count=2, raise_on_exception=True) ``` """ ... @@ -468,6 +563,8 @@ def fork( self, timeout: Optional[int] = None, count: Optional[int] = None, + *, + raise_on_exception: bool = False, **opts: Unpack[ApiParams], ) -> List[Union[Self, Exception]]: """ @@ -487,20 +584,24 @@ def fork( :param timeout: Timeout for the forked sandboxes in **seconds**. :param count: Number of forked sandboxes to create. An integer between 1 and 20. Omitted, the API default applies. + :param raise_on_exception: Raise `SandboxForkException` with all results if any fork failed to start. :return: List with one entry per requested fork — a sandbox instance or an exception + :raises SandboxForkException: when `raise_on_exception` is `True` and at least one fork failed to start. @example ```python sandbox = Sandbox.create() fork1, fork2 = sandbox.fork(count=2) + forks = sandbox.fork(count=2, raise_on_exception=True) ``` """ return type(self)._cls_fork_sandbox( self.sandbox_id, timeout=timeout, count=count, + raise_on_exception=raise_on_exception, **self.connection_config.get_api_params(**opts), ) @@ -1125,6 +1226,8 @@ def _cls_fork_sandbox( timeout: Optional[int] = None, count: Optional[int] = None, logger: Optional[logging.Logger] = None, + *, + raise_on_exception: bool = False, **opts: Unpack[ApiParams], ) -> List[Union[Self, Exception]]: params = cls._resolve_api_params(**opts) @@ -1166,6 +1269,11 @@ def _cls_fork_sandbox( ) ) + if raise_on_exception and any( + isinstance(sandbox, Exception) for sandbox in sandboxes + ): + raise SandboxForkException(sandboxes) + return sandboxes @classmethod diff --git a/packages/python-sdk/tests/shared/sandbox/test_fork_raise_on_exception.py b/packages/python-sdk/tests/shared/sandbox/test_fork_raise_on_exception.py new file mode 100644 index 0000000000..6396da89ac --- /dev/null +++ b/packages/python-sdk/tests/shared/sandbox/test_fork_raise_on_exception.py @@ -0,0 +1,215 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from e2b import ( + AsyncSandbox, + RateLimitException, + Sandbox, + SandboxException, + SandboxForkException, +) +from e2b.api.client.api.sandboxes import ( + post_sandboxes_sandbox_id_fork, + post_v2_sandboxes, +) +from e2b.api.client.models import ( + Error as ErrorModel, + Sandbox as SandboxModel, + SandboxForkResult, +) + + +def _created_sandbox(): + return SimpleNamespace( + status_code=200, + parsed=SandboxModel( + client_id="client-id", + envd_version="0.2.4", + sandbox_id="source-sandbox", + template_id="base", + ), + ) + + +def _fork_results(include_error=True): + results = [ + SandboxForkResult( + sandbox=SandboxModel( + client_id="client-id", + envd_version="0.2.4", + sandbox_id="fork-1", + template_id="base", + ) + ) + ] + if include_error: + results.append( + SandboxForkResult(error=ErrorModel(code=429, message="rate limited")) + ) + else: + results.append( + SandboxForkResult( + sandbox=SandboxModel( + client_id="client-id", + envd_version="0.2.4", + sandbox_id="fork-2", + template_id="base", + ) + ) + ) + return results + + +def _mock_sync_fork(monkeypatch, include_error=True): + request = Mock( + return_value=SimpleNamespace( + status_code=200, + parsed=_fork_results(include_error), + ) + ) + monkeypatch.setattr(post_sandboxes_sandbox_id_fork, "sync_detailed", request) + return request + + +def _mock_async_fork(monkeypatch, include_error=True): + request = AsyncMock( + return_value=SimpleNamespace( + status_code=200, + parsed=_fork_results(include_error), + ) + ) + monkeypatch.setattr(post_sandboxes_sandbox_id_fork, "asyncio_detailed", request) + return request + + +def _create_sync_sandbox(monkeypatch, api_key): + monkeypatch.setattr( + post_v2_sandboxes, + "sync_detailed", + Mock(return_value=_created_sandbox()), + ) + return Sandbox.create(api_key=api_key) + + +async def _create_async_sandbox(monkeypatch, api_key): + monkeypatch.setattr( + post_v2_sandboxes, + "asyncio_detailed", + AsyncMock(return_value=_created_sandbox()), + ) + return await AsyncSandbox.create(api_key=api_key) + + +def _assert_failed_fork_exception(error, sandbox_class): + assert isinstance(error, SandboxException) + assert len(error.results) == 2 + assert isinstance(error.results[0], sandbox_class) + assert error.results[0].sandbox_id == "fork-1" + assert isinstance(error.results[1], RateLimitException) + assert "1 of 2" in str(error) + assert "rate limited" in str(error) + + +def test_sync_fork_returns_sandbox_and_exception_by_default(monkeypatch, test_api_key): + request = _mock_sync_fork(monkeypatch) + + results = Sandbox.fork("source-sandbox", api_key=test_api_key) + + assert len(results) == 2 + assert isinstance(results[0], Sandbox) + assert isinstance(results[1], RateLimitException) + assert "raise_on_exception" not in request.call_args.kwargs["body"].to_dict() + + +def test_sync_static_fork_raises_with_full_results(monkeypatch, test_api_key): + _mock_sync_fork(monkeypatch) + + with pytest.raises(SandboxForkException) as exc_info: + Sandbox.fork( + "source-sandbox", + api_key=test_api_key, + raise_on_exception=True, + ) + + _assert_failed_fork_exception(exc_info.value, Sandbox) + + +def test_sync_instance_fork_raises_with_full_results(monkeypatch, test_api_key): + sandbox = _create_sync_sandbox(monkeypatch, test_api_key) + _mock_sync_fork(monkeypatch) + + with pytest.raises(SandboxForkException) as exc_info: + sandbox.fork(raise_on_exception=True) + + _assert_failed_fork_exception(exc_info.value, Sandbox) + + +def test_sync_fork_returns_sandboxes_when_all_succeed(monkeypatch, test_api_key): + _mock_sync_fork(monkeypatch, include_error=False) + + forks = Sandbox.fork( + "source-sandbox", + api_key=test_api_key, + raise_on_exception=True, + ) + + assert len(forks) == 2 + assert all(isinstance(fork, Sandbox) for fork in forks) + + +async def test_async_fork_returns_sandbox_and_exception_by_default( + monkeypatch, test_api_key +): + request = _mock_async_fork(monkeypatch) + + results = await AsyncSandbox.fork("source-sandbox", api_key=test_api_key) + + assert len(results) == 2 + assert isinstance(results[0], AsyncSandbox) + assert isinstance(results[1], RateLimitException) + assert "raise_on_exception" not in request.call_args.kwargs["body"].to_dict() + + +async def test_async_static_fork_raises_with_full_results(monkeypatch, test_api_key): + _mock_async_fork(monkeypatch) + + with pytest.raises(SandboxForkException) as exc_info: + await AsyncSandbox.fork( + "source-sandbox", + api_key=test_api_key, + raise_on_exception=True, + ) + + _assert_failed_fork_exception(exc_info.value, AsyncSandbox) + + +async def test_async_instance_fork_raises_with_full_results(monkeypatch, test_api_key): + sandbox = await _create_async_sandbox(monkeypatch, test_api_key) + _mock_async_fork(monkeypatch) + + with pytest.raises(SandboxForkException) as exc_info: + await sandbox.fork(raise_on_exception=True) + + error = exc_info.value + assert isinstance(error, SandboxException) + assert len(error.results) == 2 + assert isinstance(error.results[0], AsyncSandbox) + assert error.results[0].sandbox_id == "fork-1" + assert isinstance(error.results[1], RateLimitException) + assert "1 of 2" in str(error) + assert "rate limited" in str(error) + + +async def test_async_fork_returns_sandboxes_when_all_succeed(monkeypatch, test_api_key): + _mock_async_fork(monkeypatch, include_error=False) + + forks = await AsyncSandbox.fork( + "source-sandbox", + api_key=test_api_key, + raise_on_exception=True, + ) + + assert len(forks) == 2 + assert all(isinstance(fork, AsyncSandbox) for fork in forks)