Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/calm-forks-raise.md
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.
24 changes: 24 additions & 0 deletions packages/js-sdk/src/errors.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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<Sandbox | Error>

constructor(results: Array<Sandbox | Error>) {
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('; ')}`
Comment on lines +71 to +73

Copy link
Copy Markdown
Contributor Author

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:

Suggested change
`${failedErrors.length} of ${results.length} forks failed to start: ${failedErrors
.map((error) => error.message)
.join('; ')}`
`${failedErrors.length} of ${results.length} forks failed to start: ${failedErrors
.map((error) => error.message)
.join('; ')}. The forks that did start are still running — use \`error.results\` to keep or kill them.`

)
this.name = 'SandboxForkError'
this.results = results
}
}

/**
* Thrown when a timeout error occurs.
*
Expand Down
1 change: 1 addition & 0 deletions packages/js-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
NotEnoughSpaceError,
NotFoundError,
SandboxError,
SandboxForkError,
SandboxNotFoundError,
SandboxNotRunningError,
SandboxUnreachableError,
Expand Down
95 changes: 93 additions & 2 deletions packages/js-sdk/src/sandbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T-25 / T-72 — each overload carries its own docs, and @returns must say what this variant returns. This doc block now belongs to the throwOnError: true overload, which resolves to Sandbox[] only, but it still says "a sandbox instance or an error" (and the paragraph above still describes the allSettled-style mixed array). Describe the narrowed return here:

Suggested change
* @returns array with one entry per requested fork — a sandbox instance or an error.
* @returns array of running sandbox instances, one per requested fork, in request order.

* @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,
Expand All @@ -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
}

/**
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T-25 / T-72 — each overload carries its own docs, and @returns must say what this variant returns. This doc block now belongs to the throwOnError: true overload, which resolves to Sandbox[] only, but it still says "a sandbox instance or an error" (and the paragraph above still describes the allSettled-style mixed array). Describe the narrowed return here:

Suggested change
* @returns array with one entry per requested fork — a sandbox instance or an error.
* @returns array of running sandbox instances, one per requested fork, in request order.

* @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<
Expand Down
9 changes: 9 additions & 0 deletions packages/js-sdk/src/sandbox/sandboxApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
121 changes: 121 additions & 0 deletions packages/js-sdk/tests/sandbox/forkThrowOnError.test.ts
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)
})
2 changes: 2 additions & 0 deletions packages/python-sdk/e2b/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
RateLimitException,
ServiceBusyException,
SandboxException,
SandboxForkException,
SandboxNotFoundException,
SandboxNotRunningException,
SandboxUnreachableException,
Expand Down Expand Up @@ -185,6 +186,7 @@
"Username",
# Exceptions
"SandboxException",
"SandboxForkException",
"ServiceBusyException",
"TimeoutException",
"NotFoundException",
Expand Down
24 changes: 23 additions & 1 deletion packages/python-sdk/e2b/exceptions.py
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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor Author

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:

Suggested change
f"{len(failed_errors)} of {len(results)} forks failed to start: "
f"{'; '.join(str(error) for error in failed_errors)}"
f"{len(failed_errors)} of {len(results)} forks failed to start: "
f"{'; '.join(str(error) for error in failed_errors)}. "
"The forks that did start are still running — use `.results` to keep or kill them."

)
self.results = list(results)


class TimeoutException(SandboxException):
"""
Raised when a timeout occurs.
Expand Down
Loading
Loading