Repository navigation
feat(sdk)!: pause takes force and returns whether the sandbox was paused and whether forcefully #1983
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?
feat(sdk)!: pause takes force and returns whether the sandbox was paused and whether forcefully #1983
Changes from all commits
612f5ec
4556403
891dec7
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,17 @@ | ||
| --- | ||
| 'e2b': major | ||
| '@e2b/python-sdk': major | ||
| '@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
Member
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. 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. | ||
| 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') | ||
| }) | ||
| }) |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
Member
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. 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. | ||
| */ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,8 @@ export { | |
| TimeoutError, | ||
| RateLimitError, | ||
| ServiceBusyError, | ||
| SandboxPauseRefusedError, | ||
| SANDBOX_PAUSE_REFUSED_ERROR_CODE, | ||
|
Member
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. external is consuming SandboxPauseRefusedError |
||
| BuildError, | ||
| FileUploadError, | ||
| VolumeError, | ||
|
|
@@ -68,6 +70,7 @@ export type { | |
| SandboxForkOpts, | ||
| SandboxMetricsOpts, | ||
| SandboxPauseOpts, | ||
| SandboxPauseResult, | ||
| SandboxState, | ||
| SandboxListOpts, | ||
| SandboxListOrder, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.