Skip to content

Remove SDK-side defaults from API request payloads - #1749

Merged
mishushakov merged 24 commits into
mainfrom
devin/1787318715-remove-sdk-defaults
Sep 18, 2026
Merged

mishushakov merged 24 commits into
mainfrom
devin/1787318715-remove-sdk-defaults

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Remove SDK-side defaults from API request payloads (JS + Python sync/async) so omitted options are absent from requests and the API defaults apply. Explicit values — including false/0 — are still serialized unchanged.

Fields no longer preset when omitted:

  • sandbox create: timeout (was 300s), secure (was true), allow_internet_access
  • sandbox connect: timeout (was 300s)
  • POST /sandboxes/{id}/fork: timeout (was 300s), count (was 1)
  • POST /sandboxes/{id}/pause: memory (was true)
  • template builds: cpuCount/memoryMB (were 2 / 1024)
- secure: opts?.secure ?? true,
+ secure: opts?.secure,

- const timeoutMs = apiOpts?.timeoutMs ?? this.defaultSandboxTimeoutMs  // connect
+ const timeoutMs = apiOpts?.timeoutMs

Create/connect now use the v2 endpoints from belt #3425 (spec/openapi.yml synced via the spec/runtime-ref pin; JS + Python clients regenerated with the pinned generators):

- POST /sandboxes                      body: NewSandbox
+ POST /v2/sandboxes                   body: NewSandboxV2   // no `secure` field, envd always secured, timeout defaults to 300s
- POST /sandboxes/{id}/connect         body: ConnectSandbox   (timeout required)
+ POST /v2/sandboxes/{id}/connect      body: ConnectSandboxV2 (timeout optional, defaults to 300s)

Consequently the secure option on Sandbox.create is deprecated: it stays in the signature (JS SandboxOpts.secure, Python/desktop secure=) so existing callers keep working, but it is ignored — every sandbox is secured, so the SDK has nothing to send. Downstream packages follow: e2b-desktop (Python) no longer presets allow_internet_access=True; the code-interpreter test fixtures stop passing secure. The Python ConnectSandboxBody shim and the JS cast that worked around the required v1 connect timeout are gone too — the generated v2 models already make it optional.

Template.getBuildStatus / Template.get_build_status also stop presetting logsOffset: 0 — omitted, the query param is left out and the API default applies.

Per updated TASTE T-52 (client validation must not mirror backend business rules), the client-side fork count >= 1 pre-validation is also removed — an invalid count now surfaces as the API's own 400 error instead of a client-side InvalidArgumentError/InvalidArgumentException.

Belt #3425 is merged and exported: spec/runtime-ref is bumped to the runtime commit carrying the v2 routes, and make codegen at that pin reproduces the tracked spec and generated clients byte-for-byte. The live integration suites stay red until the API deploy with the v2 routes reaches staging/production (they 404 on /v2/sandboxes until then).

Exact-request tests added/updated in packages/js-sdk/tests/sandbox/apiDefaults.test.ts, packages/js-sdk/tests/template/apiDefaults.test.ts, and packages/python-sdk/tests/shared/{sandbox,template}/test_api_defaults.py; the msw/monkeypatch-mocked suites now intercept the v2 routes.

Usage stays the same; only the outgoing payloads/routes change:

await Sandbox.create('base')                       // POST /v2/sandboxes, no timeout/allow_internet_access sent
await Sandbox.connect(id)                          // POST /v2/sandboxes/{id}/connect, no timeout sent
await Sandbox.connect(id, { timeoutMs: 60_000 })   // timeout: 60
await Sandbox.create('base', { secure: true })     // accepted (deprecated), nothing sent

Linear: SDK-346

Link to Devin session: https://app.devin.ai/sessions/1921bb3818604f3a95b0db1272cff3cd
Open in Devin Desktop: https://app.devin.ai/desktop/session/1921bb3818604f3a95b0db1272cff3cd?variant=devin
Requested by: @mishushakov

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@cla-bot cla-bot Bot added the cla-signed label Aug 21, 2026
@changeset-bot

changeset-bot Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 11d377d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
e2b Minor
@e2b/python-sdk Minor
@e2b/desktop-python Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Package Artifacts

Built from 9674edd. Download artifacts from this workflow run.

JS SDK (e2b@2.50.1-devin-1787318715-remove-sdk-defaults.0):

npm install ./e2b-2.50.1-devin-1787318715-remove-sdk-defaults.0.tgz

CLI (@e2b/cli@2.20.1-devin-1787318715-remove-sdk-defaults.0):

npm install ./e2b-cli-2.20.1-devin-1787318715-remove-sdk-defaults.0.tgz

Code Interpreter JS SDK (@e2b/code-interpreter@2.8.1-devin-1787318715-remove-sdk-defaults.0):

npm install ./e2b-code-interpreter-2.8.1-devin-1787318715-remove-sdk-defaults.0.tgz

Desktop JS SDK (@e2b/desktop@2.4.1-devin-1787318715-remove-sdk-defaults.0):

npm install ./e2b-desktop-2.4.1-devin-1787318715-remove-sdk-defaults.0.tgz

Python SDK (e2b==2.50.0+devin.1787318715.remove.sdk.defaults):

pip install ./e2b-2.50.0+devin.1787318715.remove.sdk.defaults-py3-none-any.whl

Code Interpreter Python SDK (e2b-code-interpreter==2.10.0+devin.1787318715.remove.sdk.defaults):

pip install ./e2b_code_interpreter-2.10.0+devin.1787318715.remove.sdk.defaults-py3-none-any.whl

Desktop Python SDK (e2b-desktop==2.5.0+devin.1787318715.remove.sdk.defaults):

pip install ./e2b_desktop-2.5.0+devin.1787318715.remove.sdk.defaults-py3-none-any.whl

@devin-ai-integration devin-ai-integration Bot left a comment

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.

TASTE.md review of the SDK-side default removal.

Checked: parity across JS / sync Python / async Python (T-1, T-2), where defaults live and how they are documented (T-47), server-owns-validation (T-52), absence-is-undefined / Optional[...] = None at the boundary (T-20), and docstring/JSDoc completeness (T-69, T-71, T-72).

The mechanics are clean and applied symmetrically across all three surfaces (UNSET in Python, omitted keys in JS), and the new tests pin both the omit and the explicit-value paths. 4 violations flagged inline, all in the documentation and validation edges rather than the payload change itself.

Not tied to a changed line:

  • connect/_cls_connect still applies the SDK-side 300 s default (apiOpts?.timeoutMs ?? DEFAULT_SANDBOX_TIMEOUT_MS in sandboxApi.ts, timeout or SandboxBase.default_sandbox_timeout in both Python sandbox_api.py). After this PR create/fork defer to the API while connect does not, so the same timeout knob has two different "unset" behaviours. Either move connect over too or say in the changeset why it keeps a client default.
  • Several new doc lines restate the server's current value in prose ("currently enabled", "currently allowed", "currently 1", "currently a full memory snapshot"). That is the drift T-47 exists to avoid, without the machine-readable @default tag that made the value greppable. If the value is worth documenting, document it as @default; if it isn't, say only that the SDK omits the field and the API decides.

Comment thread packages/js-sdk/src/sandbox/index.ts
Comment thread packages/js-sdk/src/sandbox/sandboxApi.ts Outdated
Comment thread packages/js-sdk/src/sandbox/sandboxApi.ts Outdated
Comment thread packages/js-sdk/src/sandbox/sandboxApi.ts Outdated
Comment thread packages/python-sdk/e2b/sandbox_sync/sandbox_api.py Outdated
Comment thread packages/js-sdk/src/template/index.ts
devin-ai-integration Bot and others added 2 commits August 21, 2026 14:19
Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
Comment thread packages/js-sdk/src/sandbox/sandboxApi.ts Outdated
devin-ai-integration Bot and others added 2 commits August 21, 2026 14:27
Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
The BYOP surface from #1688 diverged for callers that bypass the types.
Python raised InvalidArgumentException on a proxy without a string
address; JS rebuilt the body from the known fields, so `egressProxy`
passed as a bare string sent `{}` and the caller got an API error naming
a field they never left out. Mirror the guard in buildEgressProxyBody,
the way buildIamBody already does for untyped token maps.

Both SDKs also forwarded a null/None username or password as a JSON
null, which the API rejects — `{"username": os.environ.get(...)}` on an
unset variable is the way that happens. Read it as "no credentials",
the same reading both already gave `egressProxy: null` itself, and
normalize a null username coming back out of getInfo so
SandboxEgressProxyInfo.username cannot be a null its type forbids.

The get_info example published in both CHANGELOGs for 2.41.0 subscripts
`info.network["egress_proxy"]`, which KeyErrors on every sandbox without
a proxy — SandboxNetworkInfo is total=False and the key is only set when
one is configured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
devin-ai-integration Bot and others added 2 commits August 21, 2026 14:43
Fixture sandboxes previously inherited the SDK's 300s create default;
after removing SDK-side defaults they would fall back to the API's 15s
default, making long-running integration tests flaky.

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
@mishushakov

Copy link
Copy Markdown
Member

check comments

devin-ai-integration Bot and others added 2 commits August 21, 2026 15:15
…out default, simplify order docs

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
Co-Authored-By: mish@e2b.dev <mish@e2b.dev>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale comment

Comment thread packages/js-sdk/src/sandbox/sandboxApi.ts Outdated
Co-Authored-By: mish@e2b.dev <mish@e2b.dev>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale comment

Comment thread packages/js-sdk/src/sandbox/sandboxApi.ts Outdated
@mishushakov

Copy link
Copy Markdown
Member

resolve conflicts

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
@linear-code

linear-code Bot commented Sep 9, 2026

Copy link
Copy Markdown

SDK-346

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Resolved — merged main (7f20cda). Conflicts were in the connect body (sandboxApi.ts, Python sync/async sandbox_api.py): kept the optional timeout and added main's new memory (onResume) field; ConnectSandboxBody now forwards memory. Format/lint/typecheck + api-defaults tests pass.

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
Comment thread packages/desktop-python/e2b_desktop/main.py
Comment thread packages/js-sdk/src/sandbox/sandboxApi.ts
devin-ai-integration Bot and others added 7 commits September 16, 2026 13:39
secure stays in the create signatures (JS, Python sync/async, desktop-python)
so existing callers don't hit an invalid-argument error, but it is ignored and
never serialized into NewSandboxV2. Template.getBuildStatus no longer presets
logsOffset=0; when omitted the API default applies.

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
…ct spec

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
…browser suite

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
@mishushakov
mishushakov merged commit 5c0f6eb into main Sep 18, 2026
46 checks passed
@mishushakov
mishushakov deleted the devin/1787318715-remove-sdk-defaults branch September 18, 2026 11:39
mishushakov pushed a commit that referenced this pull request Sep 30, 2026
…handle setup (#1907)

## Summary

Scheduled dead-code audit of `packages/js-sdk/src`,
`packages/python-sdk/e2b`, `packages/cli/src`. Tools: knip, `tsc
--noUnusedLocals/--noUnusedParameters`, vulture, ruff (`ARG`/`F`),
jscpd, then grep across the monorepo, tests and the docs repo. Generated
code (`*.gen.ts`, `*_pb.ts`, `*_connect.ts`, `e2b/api/client*`,
`e2b/volume/client`, `mcp.d.ts`) was not included. No behaviour change.

### Removed / de-duplicated
- **js-sdk `DEFAULT_SANDBOX_TIMEOUT_MS`** (`src/connectionConfig.ts`):
#1749 removed its last use when it dropped the SDK-side 300s default. It
isn't re-exported from `index.ts` and isn't referenced anywhere in the
monorepo.
- **python-sdk `Filesystem._envd_api_url`**
(`sandbox_sync/filesystem/filesystem.py`,
`sandbox_async/filesystem/filesystem.py`): the attribute was set in
`__init__` but never read. The `envd_api_url` constructor parameter
stays because it is still passed to the RPC and streaming clients.
- **js-sdk `Commands.connect` / `Commands.start`**
(`src/sandbox/commands/index.ts`): these had the same 20-line
`handleProcessStartEvent → new CommandHandle(...)` / `cleanup +
handleRpcErrorWithHealthCheck` block (a jscpd clone). It now lives in
one private helper:
  ```ts
private async createHandle(events, clearStartTimeout, cleanup, opts?:
Pick<CommandStartOpts, 'onStdout' | 'onStderr'>): Promise<CommandHandle>
  ```
- Patch changeset for `e2b` and `@e2b/python-sdk`.

### Public-API candidates deliberately left in place
These have no references inside the repo but users can reach them, so
removing them would be a breaking change:
- python-sdk `Sandbox.default_sandbox_timeout = 300`: nothing in the SDK
reads it since #1749, but it is a public class attribute and
`tests/test_client.py` asserts it.
- python-sdk `secure=` on `Sandbox.create` and JS `SandboxOpts.secure`:
deprecated, accepted and ignored on purpose.
- python-sdk `stream_idle_timeout` on sync `Filesystem.read` /
`Volume.read_file`, and `request_timeout` on sync streaming
`commands.connect` / `pty.create` / `pty.connect`: the docstrings say
these are ignored, and they are kept so the signatures match async.
- Carried over from #1811 with no change: js-sdk
`Sandbox.getMcpUrl()`/`getMcpToken()`, deprecated
`SandboxApi.getFullInfo()`, `ConnectionConfig.envdPort`,
`TemplateBase.toJSON()`, `LogEntry.toString()`, and exported types
referenced only in their own file (`Branded`, `PtyCreateOpts`,
`PtyConnectOpts`, `FilesystemRequestOpts`, `FilesystemListOpts`,
`WatchOpts`, `SandboxUrlOpts`, `CallableTemplate`, `DockerfileParse*`,
`BasicBuildOptions`, `*Registry`, `UploadBody`, `VolumeApiPaths`,
`GitHubMcpServer`).
- cli: `connectSandbox`, `listSandboxes`, `listSandboxLogs`,
`transformTemplateData`, token-refresh helpers, `format.ts` helpers,
`User*` / `Column` types. Knip flags these because nothing imports them,
but each one is used inside its own module. The superfluous `export` was
left alone to keep the diff small.
- Not changed: `Pty.kill` / `Pty.connect` in the js-sdk are near-clones
of the `Commands` versions, but they sit in a separate class and use the
`onData` PTY callback. Sharing that code would need a larger refactor
than this mechanical cleanup.

### Verification
- `pnpm run lint`, `pnpm run format` and `pnpm run typecheck` pass on
all packages.
- js-sdk vitest: `tests/sandbox/commands` plus the unit/connectionConfig
projects.
- python-sdk pytest: `tests/{sync,async}/sandbox_*/files` (123 tests
against live sandboxes) plus the offline filesystem/client tests.

Link to Devin session:
https://app.devin.ai/sessions/d3d4f4a2a02d491184ca8d51b4867404
Open in Devin Desktop:
https://app.devin.ai/desktop/session/d3d4f4a2a02d491184ca8d51b4867404?variant=devin

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
mishushakov pushed a commit that referenced this pull request Oct 5, 2026
…setup (#1941)

## Summary
Scheduled dead/duplicated-code sweep of `packages/js-sdk/src`,
`packages/python-sdk/e2b`, `packages/cli/src`, using knip, `tsc
--noUnusedLocals/--noUnusedParameters`, vulture, ruff (`F`/`ARG`) and
jscpd. Generated clients (`*.gen.ts`, `*_pb*`, `*_connect*`,
`e2b/api/client`, `e2b/volume/client`) were excluded. Every candidate
was then grepped across the monorepo, its tests and the docs repo.
Behaviour is unchanged. Nothing was removed or renamed in the public
API.

This run found **no new dead code**. The tools flagged nothing in the JS
SDK or CLI beyond what is already listed below. Everything vulture/ruff
flagged in Python turned out to be either used or public API. This PR
only fixes two duplications that were added or still exist since #1907.
Per AGENTS.md, the response-mapping change is applied in JS too.

### De-duplicated
- **`SandboxCreateResponse` mapping**: the same 15-line "API `Sandbox`
model → `SandboxCreateResponse`" block (narrow `domain` /
`envd_access_token` / `traffic_access_token` from `Unset` to `None`) was
copied 6 times: `_create_sandbox`, `_cls_connect` and each `_cls_fork`
result, in both `sandbox_sync/sandbox_api.py` and
`sandbox_async/sandbox_api.py`. It is now one helper,
`to_sandbox_create_response(sandbox)`, in the shared
`e2b/sandbox/sandbox_api.py`.
- **JS equivalent**: `createSandbox`, `connectSandbox` and each
`forkSandbox` result in `js-sdk/src/sandbox/sandboxApi.ts` built the
same object literal. They now call a module-private
`toSandboxCreateResponse(sandbox)`. The internal `SandboxForkResponse`
type is now `SandboxCreateResponse | Error`, and neither type is
exported.
- **`Commands._start` / `Commands.connect`** (sync and async): both
ended with the same "read start event → `extract_start_pid` → build
`CommandHandle` / on error close stream +
`handle_rpc_exception_with_health`" block. It now lives in a private
`_create_handle(events, action, ...)`. This is the Python version of the
JS `Commands.createHandle` from #1907. The `extract_start_pid` error
messages ("start process" / "connect to process") are kept.

Patch changeset for `@e2b/python-sdk` and `e2b`.

### Not repeated here (already in open draft PRs)
#1766 and #1873 already cover the JS/Python `uploadUrl`/`downloadUrl`
signing duplication, the JS volume/secret response tails,
`handleProcessStartEvent`/`handleWatchDirStartEvent`, the CLI
`init`/`migrate` language list and the CLI's superfluous `export`s.
Those were not duplicated here.

### Public-API candidates deliberately left in place
- Python `Sandbox.default_sandbox_timeout`: nothing reads it since
#1749, but it is a public class attribute and `tests/test_client.py`
asserts it.
- Python `secure=` on `Sandbox.create` / JS `SandboxOpts.secure`:
deprecated, accepted and ignored on purpose.
- Python sync `stream_idle_timeout` (`Filesystem.read`,
`Volume.read_file`) and `request_timeout` (sync
`commands.connect`/`_start`, `pty.create`/`connect`): the docstrings say
these are ignored, and they are kept so the signatures match async.
- JS `Sandbox.getMcpUrl()`/`getMcpToken()`, deprecated
`SandboxApi.getFullInfo()`, `ConnectionConfig.envdPort`,
`TemplateBase.toJSON()`, `LogEntry.toString()`; Python `get_mcp_url`,
`ConnectionConfig.set_integration` (used by tests/integrations).
- JS types that knip reports as unused exports (`Branded`,
`PtyCreateOpts`, `PtyConnectOpts`, `FilesystemRequestOpts`,
`FilesystemListOpts`, `WatchOpts`, `SandboxUrlOpts`, `McpServer`,
`GitHubMcpServer`, `CallableTemplate`, `DockerfileParse*`,
`BasicBuildOptions`, `*Registry`, `UploadBody`, `VolumeApiPaths`): they
appear in public signatures and the generated SDK reference.
- Python `DockerfFileFinalParserInterface`: it is the return type of the
`DockerfileParserInterface.set_start_cmd` protocol and appears in the
docs.

### Left as-is (not mechanical)
- Python `Commands.kill` / `Pty.kill` (and the JS `Commands`/`Pty`
equivalents) are near-identical, but they sit on separate public
classes.
- Python volume `make_dir`/`get_info`/`update_metadata`/`write_file`
have similar response tails, but not identical ones: `update_metadata`
skips the `VolumeError` check. Merging them would change behaviour.
- `Template.build` vs `build_in_background` share signature and docs
only. CLI `template delete`/`publish` share a short
interactive-selection block with different prompts.

### Verification
- `make lint`, `make format`, `make typecheck` (ruff + ty) on python-sdk
all pass.
- Offline: `tests/shared`, `tests/test_*.py`, sync/async
`test_config_propagation` (734 passed).
- Live against E2B: sync/async `commands/`, `test_connect`, `test_fork`,
`test_create`, `test_snapshot`, `test_secure` (93 passed).
- js-sdk: `pnpm run format`, `pnpm run lint`, `pnpm run typecheck` pass.
vitest
`tests/sandbox/{create,connect,fork,apiDefaults,lifecyclePayload,lifecycleRequest,configPropagation,iam,onResumeRequest,snapshot-api,secure,snapshot,kill}.test.ts`
pass.

Link to Devin session:
https://app.devin.ai/sessions/4e5922ccd81b4c6e83225fa6d158e84b
Open in Devin Desktop:
https://app.devin.ai/desktop/session/4e5922ccd81b4c6e83225fa6d158e84b?variant=devin
<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/e2b-dev/e2b/pull/1941"
target="_blank"><picture><source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-devin-review-dark.svg?v=4"><img
src="https://static.devin.ai/assets/gh-devin-review-light.svg?v=4"
alt="Devin Review"></picture></a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
tomassrnka added a commit that referenced this pull request Oct 14, 2026
…; SandboxPauseRefusedException for a refused pause

What changed: `Sandbox.pause()` and `AsyncSandbox.pause()` (instance and
class forms, and the deprecated `beta_pause` forms, matching the
JavaScript `betaPause`) take `force: Optional[bool] = None`, sent in
the pause request body when set and omitted otherwise, like `mode`, and
return the `SandboxPauseResult` dataclass (`paused`, `forced`),
exported from `e2b` under its own section, instead of a bool: 200 gives
`paused=True, forced=body.forced`, 409 (already paused)
`paused=False, forced=False`, and a 204 from an older API
`paused=True, forced=False`.

A 503 whose body carries `error_code: "sandbox_pause_refused"` is raised
as the new `SandboxPauseRefusedException` (exported; `status_code` 503,
the `error_code` attribute, subclass of `ServiceBusyException` so
existing handlers keep working). The mapping lives in
`api_exception_from_code` / `handle_api_exception`, keyed on the body's
`error_code` and passed through for errors embedded in fork results
too, so any endpoint answering the code gets the typed exception; the
pause call site keeps only the 409 → `paused=False` case. The message
is the API's reason followed by what to do (retry; when the reason is
an unresponsive guest agent, pause with force takes the snapshot
without the health check, its memory may not resume, a reboot resume
recovers the disk). A 503 without the code stays a
`ServiceBusyException`, 404 raises `SandboxNotFoundException`, and 400
(force on a filesystem-only pause) is the generic `SandboxException`
carrying the status. The code string is one constant,
`SANDBOX_PAUSE_REFUSED_ERROR_CODE` in e2b.exceptions, read by the
exception class, the mapping and the retry predicate.

`RetryableTransport` / `AsyncRetryableTransport`, which retry 503s the
same way the JavaScript transport does, read the body of a 503 and
return it when its `error_code` is non-retryable, so a refusal surfaces
after one request; other 503s are retried as before.

Docstrings on every overload and on the exception describe the refusal
as the node refusing the pause with the sandbox kept running and the
reason in the message, and present force as the answer for the
unresponsive-guest reason only, with the reboot caveat. The two live
tests that asserted the old bool read `.paused`. The changeset is major
for both SDKs with the migration note.

Why: the API now refuses a memory pause when the sandbox guest agent is
unresponsive and keeps the sandbox running; callers need to tell that
apart from a busy node (retry) and need the override to snapshot
anyway, accepting that the memory may not resume (a resume with
`on_resume="reboot"` recovers the disk state). The result object is a
breaking change, decided by the operator: `forced` is informative, not
an echo of the option (the API reports `forced=False` when a forced
request joined a normal pause already in flight). `force` is
`Optional[bool]` rather than `bool = False` so an unset flag is omitted
from the payload (#1749: no SDK-side defaults in API request bodies).
The generated 200 model is imported as `SandboxPauseResultAPI` to keep
the public dataclass name.

Verification: unit tests (mocked generated client) cover force omitted /
true / false / with mode in the body; 200 with forced False and True;
forced reported False for a forced request that joined a normal pause;
204 compatibility; 409; the coded refusal raising
SandboxPauseRefusedException (a ServiceBusyException) with the reason
first and the remedy after; the busy-node refusal; a 503 with no
error_code staying ServiceBusyException; 400 carrying the status; 404,
for sync and async. test_api_exception covers the central mapping on
the code regardless of endpoint and a 503 with another code staying
ServiceBusyException. The transport tests assert a coded 503 is
returned after one request with its body intact and a 503 with another
code is still retried, sync and async. `ruff check`,
`ruff format --check`, `ty check` and pytest pass (205 tests across the
four touched files).
tomassrnka added a commit that referenced this pull request Oct 14, 2026
…; SandboxPauseRefusedException for a refused pause

What changed: `Sandbox.pause()` and `AsyncSandbox.pause()` (instance and
class forms, and the deprecated `beta_pause` forms, matching the
JavaScript `betaPause`) take `force: Optional[bool] = None`, sent in
the pause request body when set and omitted otherwise, like `mode`, and
return the `SandboxPauseResult` dataclass (`paused`, `forced`),
exported from `e2b` under its own section, instead of a bool: 200 gives
`paused=True, forced=body.forced`, 409 (already paused)
`paused=False, forced=False`, and a 204 from an older API
`paused=True, forced=False`.

A 503 whose body carries `error_code: "sandbox_pause_refused"` is raised
as the new `SandboxPauseRefusedException` (exported; `status_code` 503,
the `error_code` attribute, subclass of `ServiceBusyException` so
existing handlers keep working). The mapping lives in
`api_exception_from_code` / `handle_api_exception`, keyed on the body's
`error_code` and passed through for errors embedded in fork results
too, so any endpoint answering the code gets the typed exception; a
non-object JSON body (a list) carries neither field and maps as before;
the pause call site keeps only the 409 → `paused=False` case. The
message is the API's verbatim: belt's messages already name the reason
and what to do for it, so the SDK appends nothing and the general
guidance lives in the class docstring. A 503 without the code stays a
`ServiceBusyException`, 404 raises `SandboxNotFoundException`, and 400
(force on a filesystem-only pause) is the generic `SandboxException`
carrying the status. The code string is one constant,
`SANDBOX_PAUSE_REFUSED_ERROR_CODE`, exported from `e2b` and read by the
exception class, the mapping and the retry predicate.

`RetryableTransport` / `AsyncRetryableTransport`, which retry 503s the
same way the JavaScript transport does, read the body only for a 503
and return it when its `error_code` is non-retryable, so a refusal
surfaces after one request; 429 and 502 bodies are not buffered and
other 503s are retried as before.

Docstrings on every overload and on the exception describe the refusal
as the node refusing the pause with the sandbox kept running and the
reason in the message, and present force as the answer for the
unresponsive-guest reason only, with the reboot caveat. The two live
tests that asserted the old bool read `.paused`. The changeset is major
for both SDKs (minor for the CLI) with the migration note.

Why: the API now refuses a memory pause when the sandbox guest agent is
unresponsive and keeps the sandbox running; callers need to tell that
apart from a busy node (retry) and need the override to snapshot
anyway, accepting that the memory may not resume (a resume with
`on_resume="reboot"` recovers the disk state). The result object is a
breaking change, decided by the operator: `forced` is informative, not
an echo of the option (the API reports `forced=False` when a forced
request joined a normal pause already in flight). `force` is
`Optional[bool]` rather than `bool = False` so an unset flag is omitted
from the payload (#1749: no SDK-side defaults in API request bodies).
The generated 200 model is imported as `SandboxPauseResultAPI` to keep
the public dataclass name.

Verification: unit tests with the generated client mocked cover force
omitted / true / false / with mode in the body; 200 with forced False
and True; forced reported False for a forced request that joined a
normal pause; 204 compatibility; 409; the coded refusal raising
SandboxPauseRefusedException (a ServiceBusyException) with the API
message verbatim; the busy-node refusal; a 503 with no error_code
staying ServiceBusyException; 400 carrying the status; 404, for sync
and async. Tests over an httpx.MockTransport-backed ApiClient /
AsyncApiClient go through the generated client's real response parsing
for 200 with forced True and False, the 204 older-API path (parsed is
None) and a coded refusal. test_api_exception covers the central
mapping on the code regardless of endpoint, a 503 with another code and
a JSON-list 503 body staying ServiceBusyException. The transport tests
assert a coded 503 is returned after one request with its body intact
and a 503 with another code is still retried, sync and async. `ruff
check`, `ruff format --check`, `ty check` and the non-live pytest suite
pass (626 tests).
tomassrnka added a commit that referenced this pull request Oct 14, 2026
…; SandboxPauseRefusedException for a refused pause

What changed: `Sandbox.pause()` and `AsyncSandbox.pause()` (instance and
class forms, and the deprecated `beta_pause` forms, matching the
JavaScript `betaPause`) take `force: Optional[bool] = None`, sent in
the pause request body when set and omitted otherwise, like `mode`, and
return the `SandboxPauseResult` dataclass (`paused`, `forced`),
exported from `e2b` under its own section, instead of a bool: 200 gives
`paused=True, forced=body.forced`, 409 (already paused)
`paused=False, forced=False`, and a 204 from an older API
`paused=True, forced=False`.

A 503 whose body carries `error_code: "sandbox_pause_refused"` is raised
as the new `SandboxPauseRefusedException` (exported; `status_code` 503,
the `error_code` attribute, subclass of `ServiceBusyException` so
existing handlers keep working). The mapping lives in
`api_exception_from_code` / `handle_api_exception`, keyed on the body's
`error_code` and passed through for errors embedded in fork results
too, so any endpoint answering the code gets the typed exception; a
non-object JSON body (a list) carries neither field and maps as before;
the pause call site keeps only the 409 → `paused=False` case. The
message is the API's verbatim: belt's messages already name the reason
and what to do for it, so the SDK appends nothing and the general
guidance lives in the class docstring. A 503 without the code stays a
`ServiceBusyException`, 404 raises `SandboxNotFoundException`, and 400
(force on a filesystem-only pause) is the generic `SandboxException`
carrying the status. The code string is one constant,
`SANDBOX_PAUSE_REFUSED_ERROR_CODE`, exported from `e2b` and read by the
exception class, the mapping and the retry predicate.

`RetryableTransport` / `AsyncRetryableTransport`, which retry 503s the
same way the JavaScript transport does, read the body only for a 503
whose Content-Length is known and at most 8 KiB, inside a try: a read
that fails or times out (the request's read timeout bounds it per
chunk) decides nothing and the 503 is retried as before, where an
earlier draft let the exception escape after one request; a body
carrying the code is returned after one request. 429 and 502 bodies
are not buffered. The exception docstring notes that its message has no
"503: " prefix (the API message verbatim).

`@e2b/code-interpreter`, `@e2b/desktop`, `@e2b/code-interpreter-python`
and `@e2b/desktop-python` take the same major in the changeset: the
JavaScript two re-export `e2b` and subclass its Sandbox, the Python two
subclass the Python Sandbox, and `updateInternalDependencies: patch`
would otherwise ship the breaking `pause()` in a patch of theirs. The
two Python dependents' `e2b` bound is raised from `<3.0.0` to `<4.0.0`
so they keep resolving against e2b 3.x (their locks are unchanged: they
resolve the workspace e2b). `pnpm changeset status --verbose` lists
exactly those six majors and the CLI minor.

Docstrings on every overload and on the exception describe the refusal
as the node refusing the pause with the sandbox kept running and the
reason in the message, and present force as the answer for the
unresponsive-guest reason only, with the reboot caveat. The two live
tests that asserted the old bool read `.paused`. The changeset is major
for both SDKs (minor for the CLI) with the migration note.

Why: the API now refuses a memory pause when the sandbox guest agent is
unresponsive and keeps the sandbox running; callers need to tell that
apart from a busy node (retry) and need the override to snapshot
anyway, accepting that the memory may not resume (a resume with
`on_resume="reboot"` recovers the disk state). The result object is a
breaking change, decided by the operator: `forced` is informative, not
an echo of the option (the API reports `forced=False` when a forced
request joined a normal pause already in flight). `force` is
`Optional[bool]` rather than `bool = False` so an unset flag is omitted
from the payload (#1749: no SDK-side defaults in API request bodies).
The generated 200 model is imported as `SandboxPauseResultAPI` to keep
the public dataclass name.

Verification: unit tests with the generated client mocked cover force
omitted / true / false / with mode in the body; 200 with forced False
and True; forced reported False for a forced request that joined a
normal pause; 204 compatibility; 409; the coded refusal raising
SandboxPauseRefusedException (a ServiceBusyException) with the API
message verbatim; the busy-node refusal; a 503 with no error_code
staying ServiceBusyException; 400 carrying the status; 404, for sync
and async. Tests over an httpx.MockTransport-backed ApiClient /
AsyncApiClient go through the generated client's real response parsing
for 200 with forced True and False, the 204 older-API path (parsed is
None) and a coded refusal, sync and async. test_api_exception covers the central
mapping on the code regardless of endpoint, a 503 with another code and
a JSON-list 503 body staying ServiceBusyException. The transport tests
assert a coded 503 is returned after one request with its body intact,
a 503 with another code is still retried, an oversized or unknown-length
body is retried without being read, and a 503 whose body read raises
ReadTimeout is retried (200 after two requests), sync and async. `ruff
check`, `ruff format --check`, `ty check` and the non-live pytest suite
pass (632 tests).
tomassrnka added a commit that referenced this pull request Oct 14, 2026
…; SandboxPauseRefusedException for a refused pause

What changed: `Sandbox.pause()` and `AsyncSandbox.pause()` (instance and
class forms, and the deprecated `beta_pause` forms, matching the
JavaScript `betaPause`) take `force: Optional[bool] = None`, sent in
the pause request body when set and omitted otherwise, like `mode`, and
return the `SandboxPauseResult` dataclass (`paused`, `forced`),
exported from `e2b` under its own section, instead of a bool: 200 gives
`paused=True, forced=body.forced`, 409 (already paused)
`paused=False, forced=False`, and a 204 from an older API
`paused=True, forced=False`.

A 503 whose body carries `error_code: "sandbox_pause_refused"` is raised
as the new `SandboxPauseRefusedException` (exported; `status_code` 503,
the `error_code` attribute, subclass of `ServiceBusyException` so
existing handlers keep working). The mapping lives in
`api_exception_from_code` / `handle_api_exception`, keyed on the body's
`error_code` and passed through for errors embedded in fork results
too, so any endpoint answering the code gets the typed exception; a
non-object JSON body (a list) carries neither field and maps as before;
the pause call site keeps only the 409 → `paused=False` case. The
message is the API's verbatim: belt's messages already name the reason
and what to do for it, so the SDK appends nothing and the general
guidance lives in the class docstring. A 503 without the code stays a
`ServiceBusyException`, 404 raises `SandboxNotFoundException`, and 400
(force on a filesystem-only pause) is the generic `SandboxException`
carrying the status. The code string is one constant,
`SANDBOX_PAUSE_REFUSED_ERROR_CODE`, exported from `e2b` and read by the
exception class, the mapping and the retry predicate.

`RetryableTransport` / `AsyncRetryableTransport`, which retry 503s the
same way the JavaScript transport does, read the body only for a 503
that is JSON with a known Content-Length of at most 8 KiB, inside a
try: a read that fails or times out (the request's read timeout bounds
it per chunk) decides nothing and the 503 is retried as before, where
an earlier draft let the exception escape after one request; the time
the read took is subtracted from the following sleep and the deadline
re-checked, as the JavaScript side does; a body carrying the code is
returned after one request. 429 and 502 bodies
are not buffered. The exception docstring notes that its message has no
"503: " prefix (the API message verbatim).

`@e2b/code-interpreter`, `@e2b/desktop`, `@e2b/code-interpreter-python`
and `@e2b/desktop-python` take the same major in the changeset: the
JavaScript two re-export `e2b` and subclass its Sandbox, the Python two
subclass the Python Sandbox, and `updateInternalDependencies: patch`
would otherwise ship the breaking `pause()` in a patch of theirs. The
two Python dependents' `e2b` bound is raised from `<3.0.0` to `<4.0.0`
so they keep resolving against e2b 3.x (their locks are unchanged: they
resolve the workspace e2b). `pnpm changeset status --verbose` lists
exactly those six majors and the CLI minor.

Docstrings on every overload and on the exception describe the refusal
as the node refusing the pause with the sandbox kept running and the
reason in the message, and present force as the answer for the
unresponsive-guest reason only, with the reboot caveat. The two live
tests that asserted the old bool read `.paused`. The changeset is major
for both SDKs (minor for the CLI) with the migration note.

Why: the API now refuses a memory pause when the sandbox guest agent is
unresponsive and keeps the sandbox running; callers need to tell that
apart from a busy node (retry) and need the override to snapshot
anyway, accepting that the memory may not resume (a resume with
`on_resume="reboot"` recovers the disk state). The result object is a
breaking change, decided by the operator: `forced` is informative, not
an echo of the option (the API reports `forced=False` when a forced
request joined a normal pause already in flight). `force` is
`Optional[bool]` rather than `bool = False` so an unset flag is omitted
from the payload (#1749: no SDK-side defaults in API request bodies).
The generated 200 model is imported as `SandboxPauseResultAPI` to keep
the public dataclass name.

Verification: unit tests with the generated client mocked cover force
omitted / true / false / with mode in the body; 200 with forced False
and True; forced reported False for a forced request that joined a
normal pause; 204 compatibility; 409; the coded refusal raising
SandboxPauseRefusedException (a ServiceBusyException) with the API
message verbatim; the busy-node refusal; a 503 with no error_code
staying ServiceBusyException; 400 carrying the status; 404, for sync
and async. Tests over an httpx.MockTransport-backed ApiClient /
AsyncApiClient go through the generated client's real response parsing
for 200 with forced True and False, the 204 older-API path (parsed is
None) and a coded refusal, sync and async. test_api_exception covers the central
mapping on the code regardless of endpoint, a 503 with another code and
a JSON-list 503 body staying ServiceBusyException. The transport tests
assert a coded 503 is returned after one request with its body intact,
a 503 with another code is still retried, an oversized, unknown-length or
non-JSON body is retried without being read, the read time counts
towards the delay, and a 503 whose body read raises
ReadTimeout is retried (200 after two requests), sync and async. `ruff
check`, `ruff format --check`, `ty check` and the non-live pytest suite
pass (633 tests).
tomassrnka added a commit that referenced this pull request Oct 14, 2026
…; SandboxPauseRefusedException for a refused pause

What changed: `Sandbox.pause()` and `AsyncSandbox.pause()` (instance and
class forms, and the deprecated `beta_pause` forms, matching the
JavaScript `betaPause`) take `force: Optional[bool] = None`, sent in
the pause request body when set and omitted otherwise, like `mode`, and
return the `SandboxPauseResult` dataclass (`paused`, `forced`),
exported from `e2b` under its own section, instead of a bool: 200 gives
`paused=True, forced=body.forced`, 409 (already paused)
`paused=False, forced=False`, and a 204 from an older API
`paused=True, forced=False`.

A 503 whose body carries `error_code: "sandbox_pause_refused"` is raised
as the new `SandboxPauseRefusedException` (exported; `status_code` 503,
the `error_code` attribute, subclass of `ServiceBusyException` so
existing handlers keep working). The mapping lives in
`api_exception_from_code` / `handle_api_exception`, keyed on the body's
`error_code` and passed through for errors embedded in fork results
too, so any endpoint answering the code gets the typed exception; a
non-object JSON body (a list) carries neither field and maps as before;
the pause call site keeps only the 409 → `paused=False` case. The
message is the API's verbatim: belt's messages already name the reason
and what to do for it, so the SDK appends nothing and the general
guidance lives in the class docstring. A 503 without the code stays a
`ServiceBusyException`, 404 raises `SandboxNotFoundException`, and 400
(force on a filesystem-only pause) is the generic `SandboxException`
carrying the status. The code string is one constant,
`SANDBOX_PAUSE_REFUSED_ERROR_CODE`, exported from `e2b` and read by the
exception class, the mapping and the retry predicate.

`RetryableTransport` / `AsyncRetryableTransport`, which retry 503s the
same way the JavaScript transport does, read the body only for a 503
whose media type is JSON (lower-cased, parameters dropped) and that is
not declared larger than 8 KiB, streaming up to that cap through
iter_bytes / aiter_bytes inside a try, so a chunked or HTTP/2 refusal
without Content-Length is read too: a read that fails or times out
(the request's read timeout bounds it per chunk) or exceeds the cap
decides nothing and the 503 is retried as before, where an earlier
draft let the exception escape after one request; the time the read
took is subtracted from the following sleep and the deadline
re-checked, as the JavaScript side does; a body carrying the code is
returned after one request with the bytes read as its body (encoding
and length headers dropped, the content is decoded), so the caller can
still read it. 429 and 502 bodies
are not buffered. The exception docstring notes that its message has no
"503: " prefix (the API message verbatim).

`@e2b/code-interpreter`, `@e2b/desktop`, `@e2b/code-interpreter-python`
and `@e2b/desktop-python` take the same major in the changeset: the
JavaScript two re-export `e2b` and subclass its Sandbox, the Python two
subclass the Python Sandbox, and `updateInternalDependencies: patch`
would otherwise ship the breaking `pause()` in a patch of theirs. The
two Python dependents' `e2b` bound is raised from `<3.0.0` to `<4.0.0`
so they keep resolving against e2b 3.x (their locks are unchanged: they
resolve the workspace e2b). `pnpm changeset status --verbose` lists
exactly those six majors and the CLI minor.

Docstrings on every overload and on the exception describe the refusal
as the node refusing the pause with the sandbox kept running and the
reason in the message, and present force as the answer for the
unresponsive-guest reason only, with the reboot caveat. The two live
tests that asserted the old bool read `.paused`. The changeset is major
for both SDKs (minor for the CLI) with the migration note.

Why: the API now refuses a memory pause when the sandbox guest agent is
unresponsive and keeps the sandbox running; callers need to tell that
apart from a busy node (retry) and need the override to snapshot
anyway, accepting that the memory may not resume (a resume with
`on_resume="reboot"` recovers the disk state). The result object is a
breaking change, decided by the operator: `forced` is informative, not
an echo of the option (the API reports `forced=False` when a forced
request joined a normal pause already in flight). `force` is
`Optional[bool]` rather than `bool = False` so an unset flag is omitted
from the payload (#1749: no SDK-side defaults in API request bodies).
The generated 200 model is imported as `SandboxPauseResultAPI` to keep
the public dataclass name.

Verification: unit tests with the generated client mocked cover force
omitted / true / false / with mode in the body; 200 with forced False
and True; forced reported False for a forced request that joined a
normal pause; 204 compatibility; 409; the coded refusal raising
SandboxPauseRefusedException (a ServiceBusyException) with the API
message verbatim; the busy-node refusal; a 503 with no error_code
staying ServiceBusyException; 400 carrying the status; 404, for sync
and async. Tests over an httpx.MockTransport-backed ApiClient /
AsyncApiClient go through the generated client's real response parsing
for 200 with forced True and False, the 204 older-API path (parsed is
None) and a coded refusal, sync and async. test_api_exception covers the central
mapping on the code regardless of endpoint, a 503 with another code and
a JSON-list 503 body staying ServiceBusyException. The transport tests
assert a coded 503 is returned after one request with its body intact,
a 503 with another code is still retried, a chunked refusal without
Content-Length is returned after one request with its body readable
(sync and async), an oversized chunked body and a non-JSON body are
retried, a mixed-case Content-Type is matched by media type, the read
time counts towards the delay, and a 503 whose body read raises
ReadTimeout is retried (200 after two requests), sync and async. `ruff
check`, `ruff format --check`, `ty check` and the non-live pytest suite
pass (637 tests).
tomassrnka added a commit that referenced this pull request Oct 14, 2026
…; SandboxPauseRefusedException for a refused pause

What changed: `Sandbox.pause()` and `AsyncSandbox.pause()` (instance and
class forms, and the deprecated `beta_pause` forms, matching the
JavaScript `betaPause`) take `force: Optional[bool] = None`, sent in
the pause request body when set and omitted otherwise, like `mode`, and
return the `SandboxPauseResult` dataclass (`paused`, `forced`),
exported from `e2b` under its own section, instead of a bool: 200 gives
`paused=True, forced=body.forced`, 409 (already paused)
`paused=False, forced=False`, and a 204 from an older API
`paused=True, forced=False`.

A 503 whose body carries `error_code: "sandbox_pause_refused"` is raised
as the new `SandboxPauseRefusedException` (exported; `status_code` 503,
the `error_code` attribute, subclass of `ServiceBusyException` so
existing handlers keep working). The mapping lives in
`api_exception_from_code` / `handle_api_exception`, keyed on the body's
`error_code` and passed through for errors embedded in fork results
too, so any endpoint answering the code gets the typed exception; a
non-object JSON body (a list) carries neither field and maps as before;
the pause call site keeps only the 409 → `paused=False` case. The
message is the API's verbatim: belt's messages already name the reason
and what to do for it, so the SDK appends nothing and the general
guidance lives in the class docstring. A 503 without the code stays a
`ServiceBusyException`, 404 raises `SandboxNotFoundException`, and 400
(force on a filesystem-only pause) is the generic `SandboxException`
carrying the status. The code string is one constant,
`SANDBOX_PAUSE_REFUSED_ERROR_CODE`, exported from `e2b` and read by the
exception class, the mapping and the retry predicate.

`RetryableTransport` / `AsyncRetryableTransport`, which retry 503s the
same way the JavaScript transport does, read the body only for a 503
whose media type is JSON (lower-cased, parameters dropped) and that is
not declared larger than 8 KiB, streaming up to that cap through
iter_bytes / aiter_bytes inside a try, so a chunked or HTTP/2 refusal
without Content-Length is read too: a read that fails or times out
(the request's read timeout bounds it per chunk) or exceeds the cap
decides nothing and the 503 is retried as before, where an earlier
draft let the exception escape after one request; the time the read
took is subtracted from the following sleep and the deadline
re-checked, as the JavaScript side does; with no retry budget left the
503 is returned as the outcome, as on the JavaScript side, carrying
the body read in full or none when the read failed or passed the cap
(its stream is consumed; a bodiless 503 is still a busy error); a body
carrying the code is returned after one request with the bytes read as
its body (encoding and length headers dropped, the content is
decoded), so the caller can still read it. 429 and 502 bodies
are not buffered. The exception docstring notes that its message has no
"503: " prefix (the API message verbatim).

`@e2b/code-interpreter`, `@e2b/desktop`, `@e2b/code-interpreter-python`
and `@e2b/desktop-python` take the same major in the changeset: the
JavaScript two re-export `e2b` and subclass its Sandbox, the Python two
subclass the Python Sandbox, and `updateInternalDependencies: patch`
would otherwise ship the breaking `pause()` in a patch of theirs. The
two Python dependents' `e2b` bound is raised from `<3.0.0` to `<4.0.0`
so they keep resolving against e2b 3.x (their locks are unchanged: they
resolve the workspace e2b). `pnpm changeset status --verbose` lists
exactly those six majors and the CLI minor.

Docstrings on every overload and on the exception describe the refusal
as the node refusing the pause with the sandbox kept running and the
reason in the message, and present force as the answer for the
unresponsive-guest reason only, with the reboot caveat. The two live
tests that asserted the old bool read `.paused`. The changeset is major
for both SDKs (minor for the CLI) with the migration note.

Why: the API now refuses a memory pause when the sandbox guest agent is
unresponsive and keeps the sandbox running; callers need to tell that
apart from a busy node (retry) and need the override to snapshot
anyway, accepting that the memory may not resume (a resume with
`on_resume="reboot"` recovers the disk state). The result object is a
breaking change, decided by the operator: `forced` is informative, not
an echo of the option (the API reports `forced=False` when a forced
request joined a normal pause already in flight). `force` is
`Optional[bool]` rather than `bool = False` so an unset flag is omitted
from the payload (#1749: no SDK-side defaults in API request bodies).
The generated 200 model is imported as `SandboxPauseResultAPI` to keep
the public dataclass name.

Verification: unit tests with the generated client mocked cover force
omitted / true / false / with mode in the body; 200 with forced False
and True; forced reported False for a forced request that joined a
normal pause; 204 compatibility; 409; the coded refusal raising
SandboxPauseRefusedException (a ServiceBusyException) with the API
message verbatim; the busy-node refusal; a 503 with no error_code
staying ServiceBusyException; 400 carrying the status; 404, for sync
and async. Tests over an httpx.MockTransport-backed ApiClient /
AsyncApiClient go through the generated client's real response parsing
for 200 with forced True and False, the 204 older-API path (parsed is
None) and a coded refusal, sync and async. test_api_exception covers the central
mapping on the code regardless of endpoint, a 503 with another code and
a JSON-list 503 body staying ServiceBusyException. The transport tests
assert a coded 503 is returned after one request with its body intact,
a 503 with another code is still retried, a chunked refusal without
Content-Length is returned after one request with its body readable
(sync and async), an oversized chunked body and a non-JSON body are
retried, a mixed-case Content-Type is matched by media type, the read
time counts towards the delay, a failed read with no budget left
returns the bodiless 503 (sync and async), and a 503 whose body read raises
ReadTimeout is retried (200 after two requests), sync and async. `ruff
check`, `ruff format --check`, `ty check` and the non-live pytest suite
pass (639 tests).
tomassrnka added a commit that referenced this pull request Oct 14, 2026
…; SandboxPauseRefusedException for a refused pause

What changed: `Sandbox.pause()` and `AsyncSandbox.pause()` (instance and
class forms, and the deprecated `beta_pause` forms, matching the
JavaScript `betaPause`) take `force: Optional[bool] = None`, sent in
the pause request body when set and omitted otherwise, like `mode`, and
return the `SandboxPauseResult` dataclass (`paused`, `forced`),
exported from `e2b` under its own section, instead of a bool: 200 gives
`paused=True, forced=body.forced`, 409 (already paused)
`paused=False, forced=False`, and a 204 from an older API
`paused=True, forced=False`.

A 503 whose body carries `error_code: "sandbox_pause_refused"` is raised
as the new `SandboxPauseRefusedException` (exported; `status_code` 503,
the `error_code` attribute, subclass of `ServiceBusyException` so
existing handlers keep working). The mapping lives in
`api_exception_from_code` / `handle_api_exception`, keyed on the body's
`error_code` and passed through for errors embedded in fork results
too, so any endpoint answering the code gets the typed exception; a
non-object JSON body (a list) carries neither field and maps as before;
the pause call site keeps only the 409 → `paused=False` case. The
message is the API's verbatim: belt's messages already name the reason
and what to do for it, so the SDK appends nothing and the general
guidance lives in the class docstring. A 503 without the code stays a
`ServiceBusyException`, 404 raises `SandboxNotFoundException`, and 400
(force on a filesystem-only pause) is the generic `SandboxException`
carrying the status. The code string is one constant,
`SANDBOX_PAUSE_REFUSED_ERROR_CODE`, exported from `e2b` and read by the
exception class, the mapping and the retry predicate.

`RetryableTransport` / `AsyncRetryableTransport`, which retry 503s the
same way the JavaScript transport does, peek at the body only for a 503
whose media type is JSON (lower-cased, parameters dropped) and that is
not declared larger than 8 KiB, streaming it through iter_bytes /
aiter_bytes under the existing try with that cap and a wall-clock
budget of min(max(delay, 1 s), remaining request budget) measured with
the transport's monotonic clock across chunks (each chunk is also
bounded by the request's read timeout as pyqwest applies it, none when
the request timeout is disabled; a chunk whose wait began within the
budget is kept, only waiting for more past it gives up), so a chunked
or HTTP/2 refusal without Content-Length is read and a trickling body
cannot hold the loop: a read that fails, times out, exceeds the cap or
outlives the budget decides nothing and the 503 is closed and retried,
where an earlier draft let the exception escape after one request; the
time the read took is subtracted from the following sleep and the
deadline re-checked, as the JavaScript side does; with no retry budget
left, a body read in full is returned as the outcome, a failed read
re-raises its error and a partial read raises the retry-budget timeout,
never a fabricated body (the API client parses every 503 body as JSON).
A refusal is returned after one request as a response rebuilt from the
bytes read: the transport's response carries no request yet (httpx
attaches it after the transport returns; pyqwest's arrive without one),
so the handled request is attached explicitly, Content-Encoding,
Transfer-Encoding and Content-Length are dropped (the content is
decoded and complete; httpx sets the length), and only plain
extensions such as http_version are copied, never pyqwest's
get_trailers bound to the closed native response. The earlier draft
read response.request and raised RuntimeError on the production
transport for every refused pause; the pause tests missed it because
their fakes attached request= and bypassed the retry layer.

`@e2b/code-interpreter`, `@e2b/desktop`, `@e2b/code-interpreter-python`
and `@e2b/desktop-python` take the same major in the changeset: the
JavaScript two re-export `e2b` and subclass its Sandbox, the Python two
subclass the Python Sandbox, and `updateInternalDependencies: patch`
would otherwise ship the breaking `pause()` in a patch of theirs. The
two Python dependents' `e2b` bound is raised from `<3.0.0` to `<4.0.0`
so they keep resolving against e2b 3.x (their locks are unchanged: they
resolve the workspace e2b). `pnpm changeset status --verbose` lists
exactly those six majors and the CLI minor.

Docstrings on every overload and on the exception describe the refusal
as the node refusing the pause with the sandbox kept running and the
reason in the message, and present force as the answer for the
unresponsive-guest reason only, with the reboot caveat. The two live
tests that asserted the old bool read `.paused`. The changeset is major
for both SDKs (minor for the CLI) with the migration note.

Why: the API now refuses a memory pause when the sandbox guest agent is
unresponsive and keeps the sandbox running; callers need to tell that
apart from a busy node (retry) and need the override to snapshot
anyway, accepting that the memory may not resume (a resume with
`on_resume="reboot"` recovers the disk state). The result object is a
breaking change, decided by the operator: `forced` is informative, not
an echo of the option (the API reports `forced=False` when a forced
request joined a normal pause already in flight). `force` is
`Optional[bool]` rather than `bool = False` so an unset flag is omitted
from the payload (#1749: no SDK-side defaults in API request bodies).
The generated 200 model is imported as `SandboxPauseResultAPI` to keep
the public dataclass name.

Verification: unit tests with the generated client mocked cover force
omitted / true / false / with mode in the body; 200 with forced False
and True; forced reported False for a forced request that joined a
normal pause; 204 compatibility; 409; the coded refusal raising
SandboxPauseRefusedException (a ServiceBusyException) with the API
message verbatim; the busy-node refusal; a 503 with no error_code
staying ServiceBusyException; 400 carrying the status; 404, for sync
and async. Tests over an httpx.MockTransport-backed ApiClient /
AsyncApiClient now run through RetryableTransport /
AsyncRetryableTransport with responses built as httpx and pyqwest hand
them over (no request=): the refusal-through-real-parsing tests fail
on the earlier draft with RuntimeError and pass here; they also cover
200 with forced True and False, the 204 older-API path (parsed is
None) and, through Sandbox.pause, a refusal body read that times out
with no budget left surfacing as the timeout. The transport fakes lost
their request= too. The transport tests assert a coded 503 is returned
after one request with its body intact, a chunked refusal without
Content-Length is returned after one request with its body readable
(sync and async), an oversized chunked body and a non-JSON body are
retried, a trickling body is retried once the peek budget is spent
(sync and async), a complete but slow body read with no budget left is
returned with its body, a failed read with no budget left re-raises
(sync and async), a mixed-case Content-Type is matched by media type,
the read time counts towards the delay, the rebuilt refusal drops the
transfer headers and native extensions and carries the request, a 503
with another code is still retried, sync and async. `ruff check`,
`ruff format --check`, `ty check` and the non-live pytest suite pass
(644 tests).
tomassrnka added a commit that referenced this pull request Oct 14, 2026
…; SandboxPauseRefusedException for a refused pause

What changed: `Sandbox.pause()` and `AsyncSandbox.pause()` (instance and
class forms, and the deprecated `beta_pause` forms, matching the
JavaScript `betaPause`) take `force: Optional[bool] = None`, sent in
the pause request body when set and omitted otherwise, like `mode`, and
return the `SandboxPauseResult` dataclass (`paused`, `forced`),
exported from `e2b` under its own section, instead of a bool: 200 gives
`paused=True, forced=body.forced`, 409 (already paused)
`paused=False, forced=False`, and a 204 from an older API
`paused=True, forced=False`.

A 503 whose body carries `error_code: "sandbox_pause_refused"` is raised
as the new `SandboxPauseRefusedException` (exported; `status_code` 503,
the `error_code` attribute, subclass of `ServiceBusyException` so
existing handlers keep working). The mapping lives in
`api_exception_from_code` / `handle_api_exception`, keyed on the body's
`error_code` and passed through for errors embedded in fork results
too, so any endpoint answering the code gets the typed exception; a
non-object JSON body (a list) carries neither field and maps as before;
the pause call site keeps only the 409 → `paused=False` case. The
message is the API's verbatim: belt's messages already name the reason
and what to do for it, so the SDK appends nothing and the general
guidance lives in the class docstring. A 503 without the code stays a
`ServiceBusyException`, 404 raises `SandboxNotFoundException`, and 400
(force on a filesystem-only pause) is the generic `SandboxException`
carrying the status. The code string is one constant,
`SANDBOX_PAUSE_REFUSED_ERROR_CODE`, exported from `e2b` and read by the
exception class, the mapping and the retry predicate.

`RetryableTransport` / `AsyncRetryableTransport`, which retry 503s the
same way the JavaScript transport does, peek at the body only for a 503
whose media type is JSON (lower-cased, parameters dropped) and that is
not declared larger than 8 KiB, streaming it through iter_bytes /
aiter_bytes under the existing try with that cap and a wall-clock
budget of min(max(delay, 1 s), remaining request budget) measured with
the transport's monotonic clock across chunks (each chunk is also
bounded by the request's read timeout as pyqwest applies it, none when
the request timeout is disabled; a chunk whose wait began within the
budget is kept, only waiting for more past it gives up), so a chunked
or HTTP/2 refusal without Content-Length is read and a trickling body
cannot hold the loop: a read that fails, times out, exceeds the cap or
outlives the budget decides nothing and the 503 is closed and retried,
where an earlier draft let the exception escape after one request; the
time the read took is subtracted from the following sleep and the
deadline re-checked, as the JavaScript side does; with no retry budget
left, a body read in full is returned as the outcome, one past the byte
cap is handed on whole (the peeked chunks followed by the rest of the
stream, as before the peek existed and as the JavaScript side does), a
failed read re-raises its error and a read cut off by the wall-clock
budget raises the retry-budget timeout, never a fabricated body (the
API client parses every 503 body as JSON). The peek releases the body
generator explicitly when it gives up.
A refusal is returned after one request as a response rebuilt from the
bytes read: the transport's response carries no request yet (httpx
attaches it after the transport returns; pyqwest's arrive without one),
so the handled request is attached explicitly, Content-Encoding,
Transfer-Encoding and Content-Length are dropped (the content is
decoded and complete; httpx sets the length), and only plain
extensions such as http_version are copied, never pyqwest's
get_trailers bound to the closed native response. The earlier draft
read response.request and raised RuntimeError on the production
transport for every refused pause; the pause tests missed it because
their fakes attached request= and bypassed the retry layer.

`@e2b/code-interpreter`, `@e2b/desktop`, `@e2b/code-interpreter-python`
and `@e2b/desktop-python` take the same major in the changeset: the
JavaScript two re-export `e2b` and subclass its Sandbox, the Python two
subclass the Python Sandbox, and `updateInternalDependencies: patch`
would otherwise ship the breaking `pause()` in a patch of theirs. The
two Python dependents' `e2b` bound is raised from `<3.0.0` to `<4.0.0`
so they keep resolving against e2b 3.x (their locks are unchanged: they
resolve the workspace e2b). `pnpm changeset status --verbose` lists
exactly those six majors and the CLI minor.

Docstrings on every overload and on the exception describe the refusal
as the node refusing the pause with the sandbox kept running and the
reason in the message, and present force as the answer for the
unresponsive-guest reason only, with the reboot caveat. The two live
tests that asserted the old bool read `.paused`. The changeset is major
for both SDKs (minor for the CLI) with the migration note.

Why: the API now refuses a memory pause when the sandbox guest agent is
unresponsive and keeps the sandbox running; callers need to tell that
apart from a busy node (retry) and need the override to snapshot
anyway, accepting that the memory may not resume (a resume with
`on_resume="reboot"` recovers the disk state). The result object is a
breaking change, decided by the operator: `forced` is informative, not
an echo of the option (the API reports `forced=False` when a forced
request joined a normal pause already in flight). `force` is
`Optional[bool]` rather than `bool = False` so an unset flag is omitted
from the payload (#1749: no SDK-side defaults in API request bodies).
The generated 200 model is imported as `SandboxPauseResultAPI` to keep
the public dataclass name.

Verification: unit tests with the generated client mocked cover force
omitted / true / false / with mode in the body; 200 with forced False
and True; forced reported False for a forced request that joined a
normal pause; 204 compatibility; 409; the coded refusal raising
SandboxPauseRefusedException (a ServiceBusyException) with the API
message verbatim; the busy-node refusal; a 503 with no error_code
staying ServiceBusyException; 400 carrying the status; 404, for sync
and async. Tests over an httpx.MockTransport-backed ApiClient /
AsyncApiClient now run through RetryableTransport /
AsyncRetryableTransport with responses built as httpx and pyqwest hand
them over (no request=): the refusal-through-real-parsing tests fail
on the earlier draft with RuntimeError and pass here; they also cover
200 with forced True and False, the 204 older-API path (parsed is
None) and, through Sandbox.pause, a refusal body read that times out
with no budget left surfacing as the timeout. The transport fakes lost
their request= too. The transport tests assert a coded 503 is returned
after one request with its body intact, a chunked refusal without
Content-Length is returned after one request with its body readable
(sync and async), an oversized chunked body and a non-JSON body are
retried, a trickling body is retried once the peek budget is spent
(sync and async), a complete but slow body read with no budget left is
returned with its body, an over-cap body with no budget left surfaces
the 503 whole (sync and async), a read cut off by the budget with no
budget left is the retry-budget timeout (sync and async, and through
Sandbox.pause), a failed read with no budget left re-raises (sync and
async), a mixed-case Content-Type is matched by media type,
the read time counts towards the delay, the rebuilt refusal drops the
transfer headers and native extensions and carries the request, a 503
with another code is still retried, sync and async. `ruff check`,
`ruff format --check`, `ty check` and the non-live pytest suite pass (649 tests); `make lint` exits 0 and a second `ruff format` leaves
every file unchanged.
tomassrnka added a commit that referenced this pull request Oct 14, 2026
…; SandboxPauseRefusedException for a refused pause

What changed: `Sandbox.pause()` and `AsyncSandbox.pause()` (instance and
class forms, and the deprecated `beta_pause` forms, matching the
JavaScript `betaPause`) take `force: Optional[bool] = None`, sent in
the pause request body when set and omitted otherwise, like `mode`, and
return the `SandboxPauseResult` dataclass (`paused`, `forced`),
exported from `e2b` under its own section, instead of a bool: 200 gives
`paused=True, forced=body.forced`, 409 (already paused)
`paused=False, forced=False`, and a 204 from an older API
`paused=True, forced=False`.

A 503 whose body carries `error_code: "sandbox_pause_refused"` is raised
as the new `SandboxPauseRefusedException` (exported; `status_code` 503,
the `error_code` attribute, subclass of `ServiceBusyException` so
existing handlers keep working). The mapping lives in
`api_exception_from_code` / `handle_api_exception`, keyed on the body's
`error_code` and passed through for errors embedded in fork results
too, so any endpoint answering the code gets the typed exception; a
non-object JSON body (a list) carries neither field and maps as before;
the pause call site keeps only the 409 → `paused=False` case. The
message is the API's verbatim: belt's messages already name the reason
and what to do for it, so the SDK appends nothing and the general
guidance lives in the class docstring. A 503 without the code stays a
`ServiceBusyException`, 404 raises `SandboxNotFoundException`, and 400
(force on a filesystem-only pause) is the generic `SandboxException`
carrying the status. The code string is one constant,
`SANDBOX_PAUSE_REFUSED_ERROR_CODE`, exported from `e2b` and read by the
exception class, the mapping and the retry predicate.

`RetryableTransport` / `AsyncRetryableTransport`, which retry 503s the
same way the JavaScript transport does, peek at the body only for a 503
whose media type is JSON (lower-cased, parameters dropped) and that is
not declared larger than 8 KiB, streaming it through iter_bytes /
aiter_bytes under the existing try with that cap and a wall-clock
budget of min(max(delay, 1 s), remaining request budget) measured with
the transport's monotonic clock across chunks (each chunk is also
bounded by the request's read timeout as pyqwest applies it, none when
the request timeout is disabled; a chunk whose wait began within the
budget is kept, only waiting for more past it gives up), so a chunked
or HTTP/2 refusal without Content-Length is read and a trickling body
cannot hold the loop: a read that fails, times out, exceeds the cap or
outlives the budget decides nothing and the 503 is closed and retried,
where an earlier draft let the exception escape after one request; the
time the read took is subtracted from the following sleep and the
deadline re-checked, as the JavaScript side does; with no retry budget
left, the 503 is the outcome as on the JavaScript side: a body read in
full is returned as is, one stopped at the byte cap or cut off by the
peek budget is handed on whole (the peeked chunks first, then the rest
of the stream, through a resumed stream whose close releases the body
iterator and the original response), and a failed read re-raises its
error, never a fabricated body (the API client parses every 503 body
as JSON). While budget remains, the iterator a stopped peek left is
closed explicitly before the retry, so nothing is left to garbage
collection; the rebuilt response is constructed once, with httpx taking
the stream over the content when both are given.
A refusal is returned after one request as a response rebuilt from the
bytes read: the transport's response carries no request yet (httpx
attaches it after the transport returns; pyqwest's arrive without one),
so the handled request is attached explicitly, Content-Encoding,
Transfer-Encoding and Content-Length are dropped (the content is
decoded and complete; httpx sets the length), and only plain
extensions such as http_version are copied, never pyqwest's
get_trailers bound to the closed native response. The earlier draft
read response.request and raised RuntimeError on the production
transport for every refused pause; the pause tests missed it because
their fakes attached request= and bypassed the retry layer.

`@e2b/code-interpreter`, `@e2b/desktop`, `@e2b/code-interpreter-python`
and `@e2b/desktop-python` take the same major in the changeset: the
JavaScript two re-export `e2b` and subclass its Sandbox, the Python two
subclass the Python Sandbox, and `updateInternalDependencies: patch`
would otherwise ship the breaking `pause()` in a patch of theirs. The
two Python dependents' `e2b` bound is raised from `<3.0.0` to `<4.0.0`
so they keep resolving against e2b 3.x (their locks are unchanged: they
resolve the workspace e2b). `pnpm changeset status --verbose` lists
exactly those six majors and the CLI minor.

Docstrings on every overload and on the exception describe the refusal
as the node refusing the pause with the sandbox kept running and the
reason in the message, and present force as the answer for the
unresponsive-guest reason only, with the reboot caveat. The two live
tests that asserted the old bool read `.paused`. The changeset is major
for both SDKs (minor for the CLI) with the migration note.

Why: the API now refuses a memory pause when the sandbox guest agent is
unresponsive and keeps the sandbox running; callers need to tell that
apart from a busy node (retry) and need the override to snapshot
anyway, accepting that the memory may not resume (a resume with
`on_resume="reboot"` recovers the disk state). The result object is a
breaking change, decided by the operator: `forced` is informative, not
an echo of the option (the API reports `forced=False` when a forced
request joined a normal pause already in flight). `force` is
`Optional[bool]` rather than `bool = False` so an unset flag is omitted
from the payload (#1749: no SDK-side defaults in API request bodies).
The generated 200 model is imported as `SandboxPauseResultAPI` to keep
the public dataclass name.

Verification: unit tests with the generated client mocked cover force
omitted / true / false / with mode in the body; 200 with forced False
and True; forced reported False for a forced request that joined a
normal pause; 204 compatibility; 409; the coded refusal raising
SandboxPauseRefusedException (a ServiceBusyException) with the API
message verbatim; the busy-node refusal; a 503 with no error_code
staying ServiceBusyException; 400 carrying the status; 404, for sync
and async. Tests over an httpx.MockTransport-backed ApiClient /
AsyncApiClient now run through RetryableTransport /
AsyncRetryableTransport with responses built as httpx and pyqwest hand
them over (no request=): the refusal-through-real-parsing tests fail
on the earlier draft with RuntimeError and pass here; they also cover
200 with forced True and False, the 204 older-API path (parsed is
None) and, through Sandbox.pause, a refusal body read that times out
with no budget left surfacing as the timeout. The transport fakes lost
their request= too. The transport tests assert a coded 503 is returned
after one request with its body intact, a chunked refusal without
Content-Length is returned after one request with its body readable
(sync and async), an oversized chunked body and a non-JSON body are
retried, a trickling body is retried once the peek budget is spent
(sync and async), a complete but slow body read with no budget left is
returned with its body (sync and async), an over-cap body with no
budget left surfaces the 503 whole with its third chunk read only by
the resumed stream and no length or transfer-encoding header (sync and
async, and through Sandbox.pause as SandboxPauseRefusedException), a
read cut off by the budget with no budget left is handed on whole too
(sync and async, and through Sandbox.pause as the typed refusal), a failed read with no budget left re-raises (sync and
async), a mixed-case Content-Type is matched by media type,
the read time counts towards the delay, the rebuilt refusal drops the
transfer headers and native extensions and carries the request, a 503
with another code is still retried, sync and async. `ruff check`,
`ruff format --check`, `ty check` and the non-live pytest suite pass (651 tests); `make lint` exits 0 and a second `ruff format` leaves
every file unchanged.
tomassrnka added a commit that referenced this pull request Oct 14, 2026
…; SandboxPauseRefusedException for a refused pause

What changed: `Sandbox.pause()` and `AsyncSandbox.pause()` (instance and
class forms, and the deprecated `beta_pause` forms, matching the
JavaScript `betaPause`) take `force: Optional[bool] = None`, sent in
the pause request body when set and omitted otherwise, like `mode`, and
return the `SandboxPauseResult` dataclass (`paused`, `forced`),
exported from `e2b` under its own section, instead of a bool: 200 gives
`paused=True, forced=body.forced`, 409 (already paused)
`paused=False, forced=False`, and a 204 from an older API
`paused=True, forced=False`.

A 503 whose body carries `error_code: "sandbox_pause_refused"` is raised
as the new `SandboxPauseRefusedException` (exported; `status_code` 503,
the `error_code` attribute, subclass of `ServiceBusyException` so
existing handlers keep working). The mapping lives in
`api_exception_from_code` / `handle_api_exception`, keyed on the body's
`error_code` and passed through for errors embedded in fork results
too, so any endpoint answering the code gets the typed exception; a
non-object JSON body (a list) carries neither field and maps as before;
the pause call site keeps only the 409 → `paused=False` case. The
message is the API's verbatim: belt's messages already name the reason
and what to do for it, so the SDK appends nothing and the general
guidance lives in the class docstring. A 503 without the code stays a
`ServiceBusyException`, 404 raises `SandboxNotFoundException`, and 400
(force on a filesystem-only pause) is the generic `SandboxException`
carrying the status. The code string is one constant,
`SANDBOX_PAUSE_REFUSED_ERROR_CODE`, exported from `e2b` and read by the
exception class, the mapping and the retry predicate.

`RetryableTransport` / `AsyncRetryableTransport`, which retry 503s the
same way the JavaScript transport does, peek at the body only for a 503
whose media type is JSON (lower-cased, parameters dropped) and that is
not declared larger than 8 KiB, streaming it through iter_bytes /
aiter_bytes under the existing try with that cap and a wall-clock
budget of min(max(delay, 1 s), remaining request budget) measured with
the transport's monotonic clock across chunks (each chunk is also
bounded by the request's read timeout as pyqwest applies it, none when
the request timeout is disabled; a chunk whose wait began within the
budget is kept, only waiting for more past it gives up), so a chunked
or HTTP/2 refusal without Content-Length is read and a trickling body
cannot hold the loop: a read that fails, times out, exceeds the cap or
outlives the budget decides nothing and the 503 is closed and retried,
where an earlier draft let the exception escape after one request; the
time the read took is subtracted from the following sleep and the
deadline re-checked, as the JavaScript side does; with no retry budget
left, the 503 is the outcome as on the JavaScript side: a body read in
full is returned as is, one stopped at the byte cap or cut off by the
peek budget is handed on whole (the peeked chunks first, then the rest
of the stream, through a resumed stream whose close releases the body
iterator and the original response), and a failed read re-raises its
error, never a fabricated body (the API client parses every 503 body
as JSON). While budget remains, the iterator a stopped peek left is
closed explicitly before the retry, so nothing is left to garbage
collection; the rebuilt response is constructed once, with httpx taking
the stream over the content when both are given. A body nested too deep
for json.loads (RecursionError, not ValueError) is not a refusal; a
BaseException out of the peek (cancellation, KeyboardInterrupt) closes
the inner response before propagating, so the connection is not leaked;
the resumed stream closes the inner response in a finally and tolerates
a body generator a reader is still running (left to that reader).
A refusal is returned after one request as a response rebuilt from the
bytes read: the transport's response carries no request yet (httpx
attaches it after the transport returns; pyqwest's arrive without one),
so the handled request is attached explicitly, Content-Encoding,
Transfer-Encoding and Content-Length are dropped (the content is
decoded and complete; httpx sets the length), and only plain
extensions such as http_version are copied, never pyqwest's
get_trailers bound to the closed native response. The earlier draft
read response.request and raised RuntimeError on the production
transport for every refused pause; the pause tests missed it because
their fakes attached request= and bypassed the retry layer.

`@e2b/code-interpreter`, `@e2b/desktop`, `@e2b/code-interpreter-python`
and `@e2b/desktop-python` take the same major in the changeset: the
JavaScript two re-export `e2b` and subclass its Sandbox, the Python two
subclass the Python Sandbox, and `updateInternalDependencies: patch`
would otherwise ship the breaking `pause()` in a patch of theirs. The
two Python dependents' `e2b` bound is raised from `<3.0.0` to `<4.0.0`
so they keep resolving against e2b 3.x (their locks are unchanged: they
resolve the workspace e2b). `pnpm changeset status --verbose` lists
exactly those six majors and the CLI minor.

Docstrings on every overload and on the exception describe the refusal
as the node refusing the pause with the sandbox kept running and the
reason in the message, and present force as the answer for the
unresponsive-guest reason only, with the reboot caveat. The two live
tests that asserted the old bool read `.paused`. The changeset is major
for both SDKs (minor for the CLI) with the migration note.

Why: the API now refuses a memory pause when the sandbox guest agent is
unresponsive and keeps the sandbox running; callers need to tell that
apart from a busy node (retry) and need the override to snapshot
anyway, accepting that the memory may not resume (a resume with
`on_resume="reboot"` recovers the disk state). The result object is a
breaking change, decided by the operator: `forced` is informative, not
an echo of the option (the API reports `forced=False` when a forced
request joined a normal pause already in flight). `force` is
`Optional[bool]` rather than `bool = False` so an unset flag is omitted
from the payload (#1749: no SDK-side defaults in API request bodies).
The generated 200 model is imported as `SandboxPauseResultAPI` to keep
the public dataclass name.

Verification: unit tests with the generated client mocked cover force
omitted / true / false / with mode in the body; 200 with forced False
and True; forced reported False for a forced request that joined a
normal pause; 204 compatibility; 409; the coded refusal raising
SandboxPauseRefusedException (a ServiceBusyException) with the API
message verbatim; the busy-node refusal; a 503 with no error_code
staying ServiceBusyException; 400 carrying the status; 404, for sync
and async. Tests over an httpx.MockTransport-backed ApiClient /
AsyncApiClient now run through RetryableTransport /
AsyncRetryableTransport with responses built as httpx and pyqwest hand
them over (no request=): the refusal-through-real-parsing tests fail
on the earlier draft with RuntimeError and pass here; they also cover
200 with forced True and False, the 204 older-API path (parsed is
None) and, through Sandbox.pause, a refusal body read that times out
with no budget left surfacing as the timeout. The transport fakes lost
their request= too. The transport tests assert a coded 503 is returned
after one request with its body intact, a chunked refusal without
Content-Length is returned after one request with its body readable
(sync and async), an oversized chunked body and a non-JSON body are
retried, a trickling body is retried once the peek budget is spent
(sync and async), a complete but slow body read with no budget left is
returned with its body (sync and async), an over-cap body with no
budget left surfaces the 503 whole with its third chunk read only by
the resumed stream and no length or transfer-encoding header (sync and
async, and through Sandbox.pause as SandboxPauseRefusedException), a
read cut off by the budget with no budget left is handed on whole too
(sync and async, and through Sandbox.pause as the typed refusal), a
3000-deep JSON body is retried (sync and async), cancelling the peek
while it awaits a chunk closes the inner response exactly once, and
closing the handed-on response while a reader awaits its live tail
closes the inner exactly once, a failed read with no budget left re-raises (sync and
async), a mixed-case Content-Type is matched by media type,
the read time counts towards the delay, the rebuilt refusal drops the
transfer headers and native extensions and carries the request, a 503
with another code is still retried, sync and async. `ruff check`,
`ruff format --check`, `ty check` and the non-live pytest suite pass (655 tests); `make lint` exits 0 and a second `ruff format` leaves
every file unchanged.
tomassrnka added a commit that referenced this pull request Oct 14, 2026
…; SandboxPauseRefusedException for a refused pause

What changed: `Sandbox.pause()` and `AsyncSandbox.pause()` (instance and
class forms, and the deprecated `beta_pause` forms, matching the
JavaScript `betaPause`) take `force: Optional[bool] = None`, sent in
the pause request body when set and omitted otherwise, like `mode`, and
return the `SandboxPauseResult` dataclass (`paused`, `forced`),
exported from `e2b` under its own section, instead of a bool: 200 gives
`paused=True, forced=body.forced`, 409 (already paused)
`paused=False, forced=False`, and a 204 from an older API
`paused=True, forced=False`.

A 503 whose body carries `error_code: "sandbox_pause_refused"` is raised
as the new `SandboxPauseRefusedException` (exported; `status_code` 503,
the `error_code` attribute, subclass of `ServiceBusyException` so
existing handlers keep working). The mapping lives in
`api_exception_from_code` / `handle_api_exception`, keyed on the body's
`error_code` and passed through for errors embedded in fork results
too, so any endpoint answering the code gets the typed exception; a
non-object JSON body (a list) carries neither field and maps as before;
the pause call site keeps only the 409 → `paused=False` case. The
message is the API's verbatim: belt's messages already name the reason
and what to do for it, so the SDK appends nothing and the general
guidance lives in the class docstring. A 503 without the code stays a
`ServiceBusyException`, 404 raises `SandboxNotFoundException`, and 400
(force on a filesystem-only pause) is the generic `SandboxException`
carrying the status. The code string is one constant,
`SANDBOX_PAUSE_REFUSED_ERROR_CODE`, exported from `e2b` and read by the
exception class, the mapping and the retry predicate.

`RetryableTransport` / `AsyncRetryableTransport`, which retry 503s the
same way the JavaScript transport does, peek at the body only for a 503
whose media type is JSON (lower-cased, parameters dropped) and that is
not declared larger than 8 KiB, streaming it through iter_bytes /
aiter_bytes under the existing try with that cap and a wall-clock
budget of min(max(delay, 1 s), remaining request budget) measured with
the transport's monotonic clock across chunks (each chunk is also
bounded by the request's read timeout as pyqwest applies it, none when
the request timeout is disabled; a chunk whose wait began within the
budget is kept, only waiting for more past it gives up), so a chunked
or HTTP/2 refusal without Content-Length is read and a trickling body
cannot hold the loop: a read that fails, times out, exceeds the cap or
outlives the budget decides nothing and the 503 is closed and retried,
where an earlier draft let the exception escape after one request; the
time the read took is subtracted from the following sleep and the
deadline re-checked, as the JavaScript side does; with no retry budget
left, the 503 is the outcome as on the JavaScript side: a body read in
full is returned as is, one stopped at the byte cap or cut off by the
peek budget is handed on whole (the peeked chunks first, then the rest
of the stream, through a resumed stream whose close releases the body
iterator and the original response), and a failed read re-raises its
error, never a fabricated body (the API client parses every 503 body
as JSON). While budget remains, the iterator a stopped peek left is
closed explicitly before the retry, so nothing is left to garbage
collection; the rebuilt response is constructed once, with httpx taking
the stream over the content when both are given. A body nested too deep
for json.loads (RecursionError, not ValueError) is not a refusal; a
BaseException out of the peek (cancellation, KeyboardInterrupt) closes
the inner response before propagating, so the connection is not leaked;
the resumed stream closes the inner response in a finally and tolerates
a body generator a reader is still running (RuntimeError for an async
generator, ValueError for a sync one; left to that reader). An
``error_code`` that is not a string (a list or object from a proxy's or
a buggy server's body) is not a refusal rather than a TypeError out of
the transport. A peek cut off by the budget on what turns out to be the
last chunk already holds the whole refusal and returns it without a
further retry. The handed-on stream's live tail is bounded by the
request deadline: a chunk whose wait would begin past it is not waited
for, the inner response is closed and ReadTimeout is raised, so the
API client's read ends at the deadline instead of running on chunk by
chunk past the request timeout. Because the peek's time counts against
the retry delay, a budget exhausted after a peek means the clock is at
the deadline, so in practice an over-cap or cut-off body handed on at
that point yields what the peek buffered and then the timeout; a body
read in full, or buffered chunks that already form the refusal, are
returned as content and never reach the tail. The two readers'
cross-close tests therefore build the resumed streams directly with the
deadline ahead.
A refusal is returned after one request as a response rebuilt from the
bytes read: the transport's response carries no request yet (httpx
attaches it after the transport returns; pyqwest's arrive without one),
so the handled request is attached explicitly, Content-Encoding,
Transfer-Encoding and Content-Length are dropped (the content is
decoded and complete; httpx sets the length), and only plain
extensions such as http_version are copied, never pyqwest's
get_trailers bound to the closed native response. The earlier draft
read response.request and raised RuntimeError on the production
transport for every refused pause; the pause tests missed it because
their fakes attached request= and bypassed the retry layer.

`@e2b/code-interpreter`, `@e2b/desktop`, `@e2b/code-interpreter-python`
and `@e2b/desktop-python` take the same major in the changeset: the
JavaScript two re-export `e2b` and subclass its Sandbox, the Python two
subclass the Python Sandbox, and `updateInternalDependencies: patch`
would otherwise ship the breaking `pause()` in a patch of theirs. The
two Python dependents' `e2b` bound is raised from `<3.0.0` to `<4.0.0`
so they keep resolving against e2b 3.x (their locks are unchanged: they
resolve the workspace e2b). `pnpm changeset status --verbose` lists
exactly those six majors and the CLI minor.

Docstrings on every overload and on the exception describe the refusal
as the node refusing the pause with the sandbox kept running and the
reason in the message, and present force as the answer for the
unresponsive-guest reason only, with the reboot caveat. The two live
tests that asserted the old bool read `.paused`. The changeset is major
for both SDKs (minor for the CLI) with the migration note.

Why: the API now refuses a memory pause when the sandbox guest agent is
unresponsive and keeps the sandbox running; callers need to tell that
apart from a busy node (retry) and need the override to snapshot
anyway, accepting that the memory may not resume (a resume with
`on_resume="reboot"` recovers the disk state). The result object is a
breaking change, decided by the operator: `forced` is informative, not
an echo of the option (the API reports `forced=False` when a forced
request joined a normal pause already in flight). `force` is
`Optional[bool]` rather than `bool = False` so an unset flag is omitted
from the payload (#1749: no SDK-side defaults in API request bodies).
The generated 200 model is imported as `SandboxPauseResultAPI` to keep
the public dataclass name.

Verification: unit tests with the generated client mocked cover force
omitted / true / false / with mode in the body; 200 with forced False
and True; forced reported False for a forced request that joined a
normal pause; 204 compatibility; 409; the coded refusal raising
SandboxPauseRefusedException (a ServiceBusyException) with the API
message verbatim; the busy-node refusal; a 503 with no error_code
staying ServiceBusyException; 400 carrying the status; 404, for sync
and async. Tests over an httpx.MockTransport-backed ApiClient /
AsyncApiClient now run through RetryableTransport /
AsyncRetryableTransport with responses built as httpx and pyqwest hand
them over (no request=): the refusal-through-real-parsing tests fail
on the earlier draft with RuntimeError and pass here; they also cover
200 with forced True and False, the 204 older-API path (parsed is
None) and, through Sandbox.pause, a refusal body read that times out
with no budget left surfacing as the timeout. The transport fakes lost
their request= too. The transport tests assert a coded 503 is returned
after one request with its body intact, a chunked refusal without
Content-Length is returned after one request with its body readable
(sync and async), an oversized chunked body and a non-JSON body are
retried, a trickling body is retried once the peek budget is spent
(sync and async), a complete but slow body read with no budget left is
returned with its body (sync and async), an over-cap body with no
budget left surfaces the 503 whole with its third chunk read only by
the resumed stream and no length or transfer-encoding header (sync and
async, and through Sandbox.pause as SandboxPauseRefusedException), a
read cut off by the budget with no budget left is handed on whole too
(sync and async, and through Sandbox.pause as the typed refusal), a
3000-deep JSON body is retried (sync and async), cancelling the peek
while it awaits a chunk closes the inner response exactly once, and
closing the handed-on response while a reader awaits its live tail
closes the inner exactly once (an asyncio task, and a thread inside
iter_bytes), a list-valued error_code is retried (sync and async), and
a cut-off peek whose buffered chunks form the refusal returns it
without retrying and without pulling the tail (sync and async, and
through Sandbox.pause), a handed-on tail is cut at the deadline with
ReadTimeout and the inner closed once (transport sync and async, and
through Sandbox.pause for both the cut-off and the over-cap case), a failed read with no budget left re-raises (sync and
async), a mixed-case Content-Type is matched by media type,
the read time counts towards the delay, the rebuilt refusal drops the
transfer headers and native extensions and carries the request, a 503
with another code is still retried, sync and async. `ruff check`,
`ruff format --check`, `ty check` and the non-live pytest suite pass (664 tests); `make lint` exits 0 and a second `ruff format` leaves
every file unchanged.
tomassrnka added a commit that referenced this pull request Oct 14, 2026
…; SandboxPauseRefusedException for a refused pause

What changed: `Sandbox.pause()` and `AsyncSandbox.pause()` (instance and
class forms, and the deprecated `beta_pause` forms, matching the
JavaScript `betaPause`) take `force: Optional[bool] = None`, sent in
the pause request body when set and omitted otherwise, like `mode`, and
return the `SandboxPauseResult` dataclass (`paused`, `forced`),
exported from `e2b` under its own section, instead of a bool: 200 gives
`paused=True, forced=body.forced`, 409 (already paused)
`paused=False, forced=False`, and a 204 from an older API
`paused=True, forced=False`.

A 503 whose body carries `error_code: "sandbox_pause_refused"` is raised
as the new `SandboxPauseRefusedException` (exported; `status_code` 503,
the `error_code` attribute, subclass of `ServiceBusyException` so
existing handlers keep working). The mapping lives in
`api_exception_from_code` / `handle_api_exception`, keyed on the body's
`error_code` and passed through for errors embedded in fork results
too, so any endpoint answering the code gets the typed exception; a
non-object JSON body (a list) carries neither field and maps as before;
the pause call site keeps only the 409 → `paused=False` case. The
message is the API's verbatim: belt's messages already name the reason
and what to do for it, so the SDK appends nothing and the general
guidance lives in the class docstring. A 503 without the code stays a
`ServiceBusyException`, 404 raises `SandboxNotFoundException`, and 400
(force on a filesystem-only pause) is the generic `SandboxException`
carrying the status. The code string is one constant,
`SANDBOX_PAUSE_REFUSED_ERROR_CODE`, exported from `e2b` and read by the
exception class, the mapping and the retry predicate.

`RetryableTransport` / `AsyncRetryableTransport`, which retry 503s the
same way the JavaScript transport does, peek at the body only for a 503
whose media type is JSON (lower-cased, parameters dropped) and that is
not declared larger than 8 KiB, streaming it through iter_bytes /
aiter_bytes under the existing try with that cap and a wall-clock
budget of min(max(delay, 1 s), remaining request budget) measured with
the transport's monotonic clock across chunks (each chunk is also
bounded by the request's read timeout as pyqwest applies it, none when
the request timeout is disabled; a chunk whose wait began within the
budget is kept, only waiting for more past it gives up), so a chunked
or HTTP/2 refusal without Content-Length is read and a trickling body
cannot hold the loop: a read that fails, times out, exceeds the cap or
outlives the budget decides nothing and the 503 is closed and retried,
where an earlier draft let the exception escape after one request; the
time the read took is subtracted from the following sleep and the
deadline re-checked, as the JavaScript side does; with no retry budget
left, the 503 is the outcome as on the JavaScript side: a body read in
full is returned as is, one stopped at the byte cap or cut off by the
peek budget is handed on whole (the peeked chunks first, then the rest
of the stream, through a resumed stream whose close releases the body
iterator and the original response), and a failed read re-raises its
error, never a fabricated body (the API client parses every 503 body
as JSON). While budget remains, the iterator a stopped peek left is
closed explicitly before the retry, so nothing is left to garbage
collection; the rebuilt response is constructed once, with httpx taking
the stream over the content when both are given. A body nested too deep
for json.loads (RecursionError, not ValueError) is not a refusal; a
BaseException out of the peek (cancellation, KeyboardInterrupt) closes
the inner response before propagating, so the connection is not leaked;
the resumed stream closes the inner response in a finally and tolerates
a body generator a reader is still running (RuntimeError for an async
generator, ValueError for a sync one; left to that reader). An
``error_code`` that is not a string (a list or object from a proxy's or
a buggy server's body) is not a refusal rather than a TypeError out of
the transport. A peek cut off by the budget on what turns out to be the
last chunk holds one complete JSON document: it is treated as the body
read in full (a refusal is returned at once; any other complete API
error follows the normal path, retried while budget remains and
returned whole when it is exhausted) and the tail is left unread; only
a body that does not yet parse is handed on. Without this a busy 503
whose body completed in the cut-off chunk surfaced as ReadTimeout
instead of ServiceBusyException. The handed-on stream's live tail is bounded by the
request deadline: a chunk whose wait would begin past it is not waited
for, the inner response is closed and ReadTimeout is raised, so the
API client's read ends at the deadline instead of running on chunk by
chunk past the request timeout. Because the peek's time counts against
the retry delay, a budget exhausted after a peek means the clock is at
the deadline, so in practice an over-cap or cut-off body handed on at
that point yields what the peek buffered and then the timeout; a body
read in full, or buffered chunks that already form the refusal, are
returned as content and never reach the tail. The two readers'
cross-close tests therefore build the resumed streams directly with the
deadline ahead.
A refusal is returned after one request as a response rebuilt from the
bytes read: the transport's response carries no request yet (httpx
attaches it after the transport returns; pyqwest's arrive without one),
so the handled request is attached explicitly, Content-Encoding,
Transfer-Encoding and Content-Length are dropped (the content is
decoded and complete; httpx sets the length), and only plain
extensions such as http_version are copied, never pyqwest's
get_trailers bound to the closed native response. The earlier draft
read response.request and raised RuntimeError on the production
transport for every refused pause; the pause tests missed it because
their fakes attached request= and bypassed the retry layer.

`@e2b/code-interpreter`, `@e2b/desktop`, `@e2b/code-interpreter-python`
and `@e2b/desktop-python` take the same major in the changeset: the
JavaScript two re-export `e2b` and subclass its Sandbox, the Python two
subclass the Python Sandbox, and `updateInternalDependencies: patch`
would otherwise ship the breaking `pause()` in a patch of theirs. The
two Python dependents' `e2b` bound is raised from `<3.0.0` to `<4.0.0`
so they keep resolving against e2b 3.x (their locks are unchanged: they
resolve the workspace e2b). `pnpm changeset status --verbose` lists
exactly those six majors and the CLI minor.

Docstrings on every overload and on the exception describe the refusal
as the node refusing the pause with the sandbox kept running and the
reason in the message, and present force as the answer for the
unresponsive-guest reason only, with the reboot caveat. The two live
tests that asserted the old bool read `.paused`. The changeset is major
for both SDKs (minor for the CLI) with the migration note.

Why: the API now refuses a memory pause when the sandbox guest agent is
unresponsive and keeps the sandbox running; callers need to tell that
apart from a busy node (retry) and need the override to snapshot
anyway, accepting that the memory may not resume (a resume with
`on_resume="reboot"` recovers the disk state). The result object is a
breaking change, decided by the operator: `forced` is informative, not
an echo of the option (the API reports `forced=False` when a forced
request joined a normal pause already in flight). `force` is
`Optional[bool]` rather than `bool = False` so an unset flag is omitted
from the payload (#1749: no SDK-side defaults in API request bodies).
The generated 200 model is imported as `SandboxPauseResultAPI` to keep
the public dataclass name.

Verification: unit tests with the generated client mocked cover force
omitted / true / false / with mode in the body; 200 with forced False
and True; forced reported False for a forced request that joined a
normal pause; 204 compatibility; 409; the coded refusal raising
SandboxPauseRefusedException (a ServiceBusyException) with the API
message verbatim; the busy-node refusal; a 503 with no error_code
staying ServiceBusyException; 400 carrying the status; 404, for sync
and async. Tests over an httpx.MockTransport-backed ApiClient /
AsyncApiClient now run through RetryableTransport /
AsyncRetryableTransport with responses built as httpx and pyqwest hand
them over (no request=): the refusal-through-real-parsing tests fail
on the earlier draft with RuntimeError and pass here; they also cover
200 with forced True and False, the 204 older-API path (parsed is
None) and, through Sandbox.pause, a refusal body read that times out
with no budget left surfacing as the timeout. The transport fakes lost
their request= too. The transport tests assert a coded 503 is returned
after one request with its body intact, a chunked refusal without
Content-Length is returned after one request with its body readable
(sync and async), an oversized chunked body and a non-JSON body are
retried, a trickling body is retried once the peek budget is spent
(sync and async), a complete but slow body read with no budget left is
returned with its body (sync and async), an over-cap body with no
budget left surfaces the 503 whole with its third chunk read only by
the resumed stream and no length or transfer-encoding header (sync and
async, and through Sandbox.pause as SandboxPauseRefusedException), a
read cut off by the budget with no budget left is handed on whole too
(sync and async, and through Sandbox.pause as the typed refusal), a
3000-deep JSON body is retried (sync and async), cancelling the peek
while it awaits a chunk closes the inner response exactly once, and
closing the handed-on response while a reader awaits its live tail
closes the inner exactly once (an asyncio task, and a thread inside
iter_bytes), a list-valued error_code is retried (sync and async), and
a cut-off peek whose buffered chunks form the refusal returns it
without retrying and without pulling the tail (sync and async, and
through Sandbox.pause and AsyncSandbox.pause), a busy 503 whose
complete body lands in the cut-off chunk is retried while budget
remains and surfaces as ServiceBusyException through Sandbox.pause
and AsyncSandbox.pause when none is left, the over-cap deadline cut is
covered through AsyncSandbox.pause too, a handed-on tail is cut at the deadline with
ReadTimeout and the inner closed once (transport sync and async, and
through Sandbox.pause for both the cut-off and the over-cap case), a failed read with no budget left re-raises (sync and
async), a mixed-case Content-Type is matched by media type,
the read time counts towards the delay, the rebuilt refusal drops the
transfer headers and native extensions and carries the request, a 503
with another code is still retried, sync and async. `ruff check`,
`ruff format --check`, `ty check` and the non-live pytest suite pass (670 tests); `make lint` exits 0 and a second `ruff format` leaves
every file unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant