Repository navigation
feat(sdk): add throwOnError / raise_on_exception to Sandbox.fork #2003
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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. | ||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. T-25 / T-72 — each overload carries its own docs, and
Suggested change
|
||||||
| * @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<S extends typeof Sandbox>( | ||||||
| this: S, | ||||||
| sandboxId: string, | ||||||
| opts: SandboxForkOpts & { throwOnError: true } | ||||||
| ): Promise<InstanceType<S>[]> | ||||||
| /** | ||||||
| * 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<S extends typeof Sandbox>( | ||||||
| this: S, | ||||||
| sandboxId: string, | ||||||
| opts?: SandboxForkOpts | ||||||
| ): Promise<Array<InstanceType<S> | Error>> | ||||||
| static async fork<S extends typeof Sandbox>( | ||||||
| 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<S>) | ||||||
| ) | ||||||
|
|
||||||
| 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. | ||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. T-25 / T-72 — each overload carries its own docs, and
Suggested change
|
||||||
| * @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<this[]> | ||||||
| /** | ||||||
| * 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<Array<this | Error>> | ||||||
| async fork(opts?: SandboxForkOpts): Promise<Array<this | Error>> { | ||||||
| const cls = this.constructor as typeof Sandbox | ||||||
| return (await cls.fork(this.sandboxId, this.resolveApiOpts(opts))) as Array< | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, unknown> | 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<string, unknown> | ||
| 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) | ||
| }) |
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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)}" | ||||||||||||
|
Comment on lines
+79
to
+80
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. T-62 — error messages are actionable: say what to do, not just what failed. The key thing a user must act on is that the forks that did start keep running (and billing) after this is raised; that is only in the class docstring, not in the message that ends up in logs. Put it in the message:
Suggested change
|
||||||||||||
| ) | ||||||||||||
| self.results = list(results) | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| class TimeoutException(SandboxException): | ||||||||||||
| """ | ||||||||||||
| Raised when a timeout occurs. | ||||||||||||
|
|
||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
T-62 — error messages are actionable: say what to do, not just what failed. The key thing a user must act on is that the forks that did start keep running (and billing) after this is raised; that is only in the class docstring, not in the message that ends up in logs. Put it in the message: