Skip to content
Open
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
17 changes: 17 additions & 0 deletions .changeset/pause-force.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'e2b': major
'@e2b/python-sdk': major
Comment thread
tomassrnka marked this conversation as resolved.
'@e2b/code-interpreter': major
'@e2b/desktop': major
'@e2b/code-interpreter-python': major
'@e2b/desktop-python': major
'@e2b/cli': minor
Comment on lines +2 to +8

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

no major bumps

---

`pause()` now returns a result object instead of a boolean, and takes `force`.

**Breaking:** `Sandbox.pause()` / `SandboxApi.pause()` (JavaScript) and `Sandbox.pause()` / `AsyncSandbox.pause()` (Python) return `SandboxPauseResult` with `paused` (`true` if this call paused the sandbox, `false` if it was already paused) and `forced` (whether the snapshot was taken without the guest agent health check, as reported by the API). Migrate by reading `paused` where the boolean was read: `if (await sandbox.pause())` becomes `if ((await sandbox.pause()).paused)` and `if sandbox.pause():` becomes `if sandbox.pause().paused:`. A plain truthiness check on the result is now always true, so update every call site that branched on the old boolean. `@e2b/code-interpreter` and `@e2b/desktop` re-export `e2b` and subclass its `Sandbox`, and `e2b-code-interpreter` / `e2b-desktop` subclass the Python `Sandbox`, so their `pause()` changes the same way and they take the same major; the Python packages now allow `e2b<4.0.0`.

The node may refuse a pause (HTTP 503 with `error_code: "sandbox_pause_refused"`): the sandbox keeps running and the refusal surfaces as `SandboxPauseRefusedError` (JavaScript) / `SandboxPauseRefusedException` (Python), a subclass of `ServiceBusyError` / `ServiceBusyException` whose message carries the reason. When the reason is an unresponsive sandbox guest agent, the new `force` option (`{ force: true }` / `force=True`) takes the snapshot without the health check; its memory may then not resume, and a resume with `onResume: 'reboot'` / `on_resume="reboot"` recovers the disk state. Unlike other 503s, a refusal carrying this code is not replayed by the SDK's transport retries, in either SDK: it surfaces after a single request. A `204` from an older API is still a successful, unforced pause.

`e2b sandbox pause` takes `-f, --force` and says when the pause was forced. `SANDBOX_PAUSE_REFUSED_ERROR_CODE` is exported from both packages.
37 changes: 29 additions & 8 deletions packages/cli/src/commands/sandbox/pause.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,38 @@ import * as commander from 'commander'
import { ensureAPIKey } from 'src/api'
import { asBold } from 'src/utils/format'
import * as e2b from 'e2b'
import { NotFoundError } from 'e2b'
import { NotFoundError, ServiceBusyError } from 'e2b'

async function pauseSandbox(sandboxID: string, apiKey: string) {
async function pauseSandbox(
sandboxID: string,
apiKey: string,
force: boolean | undefined
) {
try {
const paused = await e2b.Sandbox.pause(sandboxID, { apiKey })
if (paused) {
console.log(`Sandbox ${asBold(sandboxID)} has been paused`)
} else {
// Left out unless given, so the SDK omits it from the request body.
const { paused, forced } = await e2b.Sandbox.pause(sandboxID, {
apiKey,
...(force ? { force: true } : {}),
})
if (!paused) {
console.log(`Sandbox ${asBold(sandboxID)} is already paused`)
} else if (forced) {
console.log(
`Sandbox ${asBold(sandboxID)} has been paused with force: the snapshot was taken without the guest agent health check, so its memory may not resume (a reboot resume recovers the disk state)`
)
} else {
console.log(`Sandbox ${asBold(sandboxID)} has been paused`)
}
} catch (err: unknown) {
if (err instanceof NotFoundError) {
console.error(`Sandbox ${asBold(sandboxID)} wasn't found`)
process.exit(1)
}
if (err instanceof ServiceBusyError) {
// The API's message says what to do: retry, or pause with --force.
console.error(err.message)
process.exit(1)
}
throw err
}
}
Expand All @@ -29,10 +46,14 @@ export const pauseCommand = new commander.Command('pause')
`pause the sandbox specified by ${asBold('<sandboxID>')}`
)
.alias('ps')
.action(async (sandboxID: string) => {
.option(
'-f, --force',
'pause even when the sandbox guest agent is unresponsive: the snapshot is taken without the health check and its memory may not resume (a reboot resume recovers the disk state)'
)
.action(async (sandboxID: string, { force }: { force?: boolean }) => {
try {
const apiKey = ensureAPIKey()
await pauseSandbox(sandboxID, apiKey)
await pauseSandbox(sandboxID, apiKey, force)
} catch (err: unknown) {
console.error(err)
process.exit(1)
Expand Down
102 changes: 102 additions & 0 deletions packages/cli/tests/commands/sandbox/pause.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
pause: vi.fn(),
ensureAPIKey: vi.fn(() => 'test-api-key'),
}))

class ServiceBusyError extends Error {}
class SandboxPauseRefusedError extends ServiceBusyError {}

vi.mock('e2b', () => ({
Sandbox: {
pause: mocks.pause,
},
NotFoundError: class NotFoundError extends Error {},
ServiceBusyError,
}))

vi.mock('../../../src/api', () => ({
ensureAPIKey: mocks.ensureAPIKey,
}))

describe('sandbox pause', () => {
let logSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined)
})

afterEach(() => {
vi.restoreAllMocks()
})

async function run(...args: string[]) {
const { pauseCommand } = await import('../../../src/commands/sandbox/pause')
await pauseCommand.parseAsync(['node', 'e2b', ...args])
}

test('pauses without force by default, leaving force out of the options', async () => {
mocks.pause.mockResolvedValue({ paused: true, forced: false })

await run('sbx-1')

expect(mocks.pause).toHaveBeenCalledWith('sbx-1', {
apiKey: 'test-api-key',
})
expect(mocks.pause.mock.calls[0][1]).not.toHaveProperty('force')
expect(logSpy.mock.calls.flat().join('\n')).toContain('has been paused')
expect(logSpy.mock.calls.flat().join('\n')).not.toContain('with force')
})

test('--force passes force and reports a forced pause', async () => {
mocks.pause.mockResolvedValue({ paused: true, forced: true })

await run('sbx-1', '--force')

expect(mocks.pause).toHaveBeenCalledWith('sbx-1', {
apiKey: 'test-api-key',
force: true,
})
expect(logSpy.mock.calls.flat().join('\n')).toContain(
'has been paused with force'
)
})

test('a forced request the API did not force is reported as a plain pause', async () => {
mocks.pause.mockResolvedValue({ paused: true, forced: false })

await run('sbx-1', '-f')

expect(logSpy.mock.calls.flat().join('\n')).not.toContain('with force')
})

test('a refused pause prints the API message, not a stack, and exits 1', async () => {
const message =
"Sandbox 'sbx-1' cannot be paused right now because the sandbox guest agent is not responding: retry, or pause with force"
mocks.pause.mockRejectedValue(new SandboxPauseRefusedError(message))
const errorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never)

await run('sbx-1')

// The first thing printed is the API message itself; with process.exit
// mocked the command carries on past it, so only the first call counts.
expect(errorSpy.mock.calls[0]).toEqual([message])
expect(exitSpy).toHaveBeenNthCalledWith(1, 1)
})

test('an already paused sandbox is reported as such', async () => {
mocks.pause.mockResolvedValue({ paused: false, forced: false })

await run('sbx-1')

expect(logSpy.mock.calls.flat().join('\n')).toContain('is already paused')
})
})
2 changes: 1 addition & 1 deletion packages/code-interpreter-python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ requires-python = ">=3.11"
dependencies = [
"httpx>=0.20.0,<1.0.0",
"attrs>=21.3.0",
"e2b>=2.55.1,<3.0.0",
"e2b>=2.55.1,<4.0.0",
]

[project.urls]
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop-python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ license = "MIT"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"e2b>=2.44.0,<3.0.0",
"e2b>=2.44.0,<4.0.0",
"requests>=2.32.3,<3",
"urllib3>=2.8.0,<3",
"pillow>=12.0.0,<13",
Expand Down
18 changes: 15 additions & 3 deletions packages/js-sdk/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { ConnectionConfig } from '../connectionConfig'
import {
AuthenticationError,
RateLimitError,
SANDBOX_PAUSE_REFUSED_ERROR_CODE,
SandboxPauseRefusedError,
ServiceBusyError,
SandboxError,
} from '../errors'
Expand All @@ -16,7 +18,9 @@ import { withRetry } from '../retry'
/**
* Map an API error code and message to the matching error class — the same
* mapping {@link handleApiError} applies to HTTP responses, usable for error
* objects embedded in response bodies (e.g. per-fork results).
* objects embedded in response bodies (e.g. per-fork results). `errorCode` is
* the body's semantic `error_code`, which refines the status for the codes
* the SDK types (a refused pause).
*/
export function apiErrorFromCode(
code: number,
Expand All @@ -25,7 +29,8 @@ export function apiErrorFromCode(
message: string,
stackTrace?: string
) => Error = SandboxError,
stackTrace?: string
stackTrace?: string,
errorCode?: string
): Error {
if (code === 401) {
const message = 'Unauthorized, please check your credentials.'
Expand All @@ -40,6 +45,12 @@ export function apiErrorFromCode(
}

if (code === 503) {
if (errorCode === SANDBOX_PAUSE_REFUSED_ERROR_CODE) {
// The API's message already says what to do for its reason.
return new SandboxPauseRefusedError(
content ? `${content}` : 'The node refused the pause, please retry'
)
}
const message = 'Service temporarily unavailable, please retry'
return new ServiceBusyError(content ? `${message} - ${content}` : message)
}
Expand Down Expand Up @@ -72,7 +83,8 @@ export function handleApiError(
status,
response.error?.message ?? response.error,
errorClass,
stackTrace
stackTrace,
response.error?.error_code
)
}

Expand Down
18 changes: 15 additions & 3 deletions packages/js-sdk/src/api/schema.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions packages/js-sdk/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,41 @@ export class ServiceBusyError extends Error {
}
}

/**
* `error_code` of the 503 the API answers a pause with when the node refused
* it. Any endpoint answering this code is mapped to
* {@link SandboxPauseRefusedError}.
*/
export const SANDBOX_PAUSE_REFUSED_ERROR_CODE = 'sandbox_pause_refused'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am thinking you can inline this into SandboxPauseRefusedError.errorCode and then in the if you check if status code == SandboxPauseRefusedError.errorCode


/**
* Thrown when the node refused a pause (HTTP 503 with
* `error_code: "sandbox_pause_refused"`). The sandbox keeps running with its
* state intact; the message is the API's, and names the reason and what to
* do about it. In general: retry, and when the reason is an unresponsive
* sandbox guest agent, pause with `force: true` to take the snapshot without
* the health check — its memory may then not resume, and a resume with
* `onResume: 'reboot'` recovers the disk state.
*
* Unlike other 503s, a refusal carrying this code is not replayed by the
* SDK's transport retries: it is a decision, not transient capacity, so it
* surfaces after a single request and the caller decides what to do.
*
* Subclass of {@link ServiceBusyError}, which a refused pause surfaced as
* before, so existing handlers keep working.
*/
export class SandboxPauseRefusedError extends ServiceBusyError {
/**
* Semantic error code of the API response: always `sandbox_pause_refused`.
*/
readonly errorCode = SANDBOX_PAUSE_REFUSED_ERROR_CODE

constructor(message: string) {
super(message)
this.name = 'SandboxPauseRefusedError'
}
}

/**
* Thrown when the build fails.
*/
Expand Down
3 changes: 3 additions & 0 deletions packages/js-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export {
TimeoutError,
RateLimitError,
ServiceBusyError,
SandboxPauseRefusedError,
SANDBOX_PAUSE_REFUSED_ERROR_CODE,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

external is consuming SandboxPauseRefusedError

BuildError,
FileUploadError,
VolumeError,
Expand Down Expand Up @@ -68,6 +70,7 @@ export type {
SandboxForkOpts,
SandboxMetricsOpts,
SandboxPauseOpts,
SandboxPauseResult,
SandboxState,
SandboxListOpts,
SandboxListOrder,
Expand Down
Loading
Loading