Skip to content

feat(sdk): idempotencyKey connection option for sandbox create - #1978

Open
devin-ai-integration[bot] wants to merge 2 commits into
devin/1792434077-should-retry-headerfrom
devin/1791891129-sandbox-create-idempotency
Open

devin-ai-integration[bot] wants to merge 2 commits into
devin/1792434077-should-retry-headerfrom
devin/1791891129-sandbox-create-idempotency

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Client side of e2b-dev/belt#3984 (idempotent POST /v2/sandboxes). Stacked on #1998, which retries API responses only when they carry Should-Retry: true. belt sends that header on a 409 idempotency_in_progress, so this PR adds no retry logic of its own.

  • New connection option: idempotencyKey in JS (ConnectionOpts/ConnectionConfig) and idempotency_key in Python (ApiParams/ConnectionConfig). The SDK doesn't generate keys. When the option is set, Sandbox.create sends it as Idempotency-Key on POST /v2/sandboxes and on no other request, and feat(sdk): retry control-plane requests only on Should-Retry: true #1998's retries reuse the key. When it's unset, nothing changes.
    await Sandbox.create('base', { idempotencyKey: 'order42sandbox' })
    Sandbox.create("base", idempotency_key="order42sandbox")
  • ConnectionConfig checks only the length (1–255 characters). A key outside that range throws InvalidArgumentError / raises InvalidArgumentException. The server validates the [a-z0-9] charset.
  • The header is set by hand instead of through codegen, because the runtime spec doesn't have it yet. Python passes it with get_api_client(config, headers=...) instead of with_headers, because with_headers would change the shared HTTP client.
  • A keyed create isn't replayed after a 502 or a network error once the request was sent. feat(sdk): retry control-plane requests only on Should-Retry: true #1998 retries only connection failures and responses marked Should-Retry: true.
  • minor changeset for e2b and @e2b/python-sdk.

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

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@cla-bot cla-bot Bot added the cla-signed label Oct 13, 2026
@changeset-bot

changeset-bot Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 03e08c2

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/code-interpreter-python Patch

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 Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Package Artifacts

Built from a1da2ca. Download artifacts from this workflow run.

JS SDK (e2b@2.56.1-devin-1791891129-sandbox-create-idempotency.0, with @e2b/dockerfile-utils@0.1.2-devin-1791891129-sandbox-create-idempotency.0):

npm install ./e2b-dockerfile-utils-0.1.2-devin-1791891129-sandbox-create-idempotency.0.tgz ./e2b-2.56.1-devin-1791891129-sandbox-create-idempotency.0.tgz

CLI (@e2b/cli@2.22.1-devin-1791891129-sandbox-create-idempotency.0):

npm install ./e2b-cli-2.22.1-devin-1791891129-sandbox-create-idempotency.0.tgz

Code Interpreter JS SDK (@e2b/code-interpreter@2.9.1-devin-1791891129-sandbox-create-idempotency.0):

npm install ./e2b-code-interpreter-2.9.1-devin-1791891129-sandbox-create-idempotency.0.tgz

Desktop JS SDK (@e2b/desktop@2.5.1-devin-1791891129-sandbox-create-idempotency.0):

npm install ./e2b-desktop-2.5.1-devin-1791891129-sandbox-create-idempotency.0.tgz

Python SDK (e2b==2.56.0+devin.1791891129.sandbox.create.idempotency, with e2b-dockerfile-utils==0.1.1+devin.1791891129.sandbox.create.idempotency):

pip install ./e2b_dockerfile_utils-0.1.1+devin.1791891129.sandbox.create.idempotency-py3-none-any.whl ./e2b-2.56.0+devin.1791891129.sandbox.create.idempotency-py3-none-any.whl

Code Interpreter Python SDK (e2b-code-interpreter==2.11.0+devin.1791891129.sandbox.create.idempotency):

pip install ./e2b_code_interpreter-2.11.0+devin.1791891129.sandbox.create.idempotency-py3-none-any.whl

Desktop Python SDK (e2b-desktop==2.7.0+devin.1791891129.sandbox.create.idempotency):

pip install ./e2b_desktop-2.7.0+devin.1791891129.sandbox.create.idempotency-py3-none-any.whl

@devin-ai-integration
devin-ai-integration Bot force-pushed the devin/1791891129-sandbox-create-idempotency branch from 41c28a7 to 0bd8f1d Compare October 19, 2026 13:42
@devin-ai-integration devin-ai-integration Bot changed the title feat(sdk): send Idempotency-Key on sandbox create and retry keyed creates feat(sdk): idempotencyKey connection option for sandbox create, retry keyed creates Oct 19, 2026
@mishushakov
mishushakov marked this pull request as ready for review October 19, 2026 13:44
@mishushakov
mishushakov self-requested a review as a code owner October 19, 2026 13:44
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-19T13:48:09.771337Z 0bd8f1d Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

devin-ai-integration[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment on lines +50 to +57
/**
* Idempotency key sent as the `Idempotency-Key` header when creating a
* sandbox. Retrying the create with the same key returns the original result
* instead of creating another sandbox; it also lets the SDK retry the create
* after a 502 or a dropped connection. Use a new key per sandbox. 1–255
* lowercase letters or digits.
*/
idempotencyKey?: string

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 (optional) Callers who pass the new key to an E2B client get the first sandbox back from every later create, or a mismatch error, instead of a new sandbox. idempotencyKey sits on ConnectionOpts/ApiParams, and the client binds those as defaults for every call. The client already strips signal because it is per-request; the key is per-request too but is not stripped. Fix: keep the key off the bound client options in both SDKs. Omit it from E2BClientOpts (client.ts:14) and E2BClientParams (client.py:17), and drop it at bind time like signal, so it is only honoured per create call.

Why this was flagged

E2BClientOpts = Omit<ConnectionOpts, 'signal'> (packages/js-sdk/src/client.ts:14) and E2BClientParams(ApiParams) (packages/python-sdk/e2b/client.py:17) now both accept the key. client.ts:75-87 and client.py:65-70 bind it as a default for every call. ClientFactory.resolveOpts → mergeOpts (connectionConfig.ts:507-530) merges it into every Sandbox.create, and sandboxApi.ts:1844 sends it as Idempotency-Key. Python does the same through _resolve_api_params and get_api_client headers in sandbox_sync/sandbox_api.py and sandbox_async/sandbox_api.py. So new E2B({ idempotencyKey: 'k' }) followed by two client.Sandbox.create() calls sends the same key twice. The server then returns the first sandbox again, or a 409 idempotency_request_mismatch when the bodies differ. On the base branch the option does not exist, and each create makes a new sandbox. The doc comment says "Use a new key per sandbox", but nothing enforces that for bound options, unlike signal, which client.ts:73-76 deletes for exactly this reason.

Verification: The constructor copies opts into boundOpts and deletes only signal (client.ts:75-76). mergeOpts starts from { ...boundOpts } and only overrides with per-call values that are not undefined (connectionConfig.ts:515-526). So a bound key survives into every create. Nothing in either SDK rejects or strips the key at client level.

Comment thread packages/js-sdk/src/retry.ts Outdated
Comment on lines +113 to +114
export function isReplayable(request: Request): boolean {
if (request.headers.has(IDEMPOTENCY_KEY_HEADER)) return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 (optional) Callers who put an Idempotency-Key in their headers option can get duplicate resources. Every POST then counts as replayable, not only sandbox create. isReplayable returns true whenever the header is present, whatever the route. Routes the server does not deduplicate (snapshot, volume, secret, fork creation) are then replayed after a 502 or a dropped connection. Fix: treat a request as replayable on the key only for the route that is keyed (POST /v2/sandboxes), and keep the per-route list for everything else. Apply the same change to is_replayable in packages/python-sdk/e2b/retry.py:70.

Why this was flagged

The trigger is a caller passing Idempotency-Key through the generic headers/api_headers option, or a header set on a Sandbox instance or an E2B client. Those headers are merged into every control-plane request, for example by get_api_params at packages/python-sdk/e2b/connection_config.py:411-415 and by ConnectionConfig.headers in JS. isReplayable (packages/js-sdk/src/retry.ts:114) and is_replayable (packages/python-sdk/e2b/retry.py:70-71) return true for any request carrying the header. So POST /sandboxes/{id}/snapshots, POST /volumes, POST /secrets and fork are retried after a 502 or a post-write network error. On the base branch these POSTs were never replayed because they are off REPLAYABLE_OPERATIONS. Per the PR description, the server deduplicates only POST /v2/sandboxes behind api-idempotency-routes. A replay of the other POSTs can therefore create a duplicate snapshot, volume or secret, or hit a 409 key mismatch. Nothing in the diff checks that the header was set by the create path and not by the user.

Verification: The new first line of isReplayable, if (request.headers.has(IDEMPOTENCY_KEY_HEADER)) return true (retry.ts:114), runs before the method/route allow-list. So any POST carrying that header is treated as replayable. Those POSTs are then replayed after a 502 or a network error that occurs after the request was written, while the base did not replay them.

Comment thread packages/js-sdk/src/retry.ts Outdated
Comment on lines 185 to 203
if (response.status === 409) {
if (!hasIdempotencyKey) return undefined
try {
const body: unknown = await response.clone().json()
if (
typeof body !== 'object' ||
body === null ||
!('error_code' in body) ||
body.error_code !== 'idempotency_in_progress'
) {
return undefined
}
} catch {
return undefined
}
}
if (response.status === 502 && !replayable) return undefined

const retryAfter = parseRetryAfter(response.headers.get('Retry-After'))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 (optional) A keyed create replayed after a dropped connection usually fails with a 409 "idempotency_in_progress" error, while the original sandbox keeps running unseen. The in-progress 409 reuses the 502/503 backoff (BACKOFF_BASE_MS 100, DEFAULT_RETRIES 3), so with no Retry-After the SDK gives up after about 0.35–0.7 s in total. Fix: when no Retry-After is sent, poll an in-progress 409 until the request deadline with a longer floor delay, and do not count those polls against retries. Do this in retry.ts and in both Python transports (retry.py _retry_delay).

Why this was flagged

Trigger: a create made with idempotencyKey/idempotency_key loses its connection after the request is written, and belt answers the replay with 409 error_code: "idempotency_in_progress" and no Retry-After. retryDelayMs (packages/js-sdk/src/retry.ts:185-207) and _retry_delay (packages/python-sdk/e2b/retry.py:99-113) fall through to backoffMs/_backoff_delay: 100·2^attempt ms with 50% jitter, capped by retries (DEFAULT_RETRIES = 3). The network-error retry already used attempt 0, so the 409s get about 0.1–0.6 s of polling before attempt === retries returns the last 409 (retry.ts:267). So handleApiError/handle_api_exception raise a generic 409 SandboxError/SandboxException. The caller never learns the sandbox ID, and the sandbox created by the first request runs until its timeout. The 60 s request deadline (retry.ts:243) would allow far more polling, but the retry count stops it first. The base branch had no keyed retry.

Verification: nit. In packages/js-sdk/src/retry.ts, retryDelayMs lets a keyed 409 with error_code === 'idempotency_in_progress' through (lines ~183-200). With no Retry-After it falls through to return backoffMs(attempt, random). Total waiting is about 350-700 ms. Python is the same (retry.py:81-83, 113). Against the base, nothing gets worse.

@devin-ai-integration
devin-ai-integration Bot force-pushed the devin/1791891129-sandbox-create-idempotency branch from 25ac1f3 to ffc5484 Compare October 19, 2026 18:41
@devin-ai-integration
devin-ai-integration Bot changed the base branch from main to devin/1792434077-should-retry-header October 19, 2026 18:41
@devin-ai-integration
devin-ai-integration Bot added this pull request to stack #1999 October 19, 2026 18:41
@devin-ai-integration devin-ai-integration Bot changed the title feat(sdk): idempotencyKey connection option for sandbox create, retry keyed creates feat(sdk): idempotencyKey connection option for sandbox create Oct 19, 2026

@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.

Devin Review found 2 new potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment on lines +254 to +261
api_client = get_api_client(
config,
headers=(
{IDEMPOTENCY_KEY_HEADER: config.idempotency_key}
if config.idempotency_key is not None
else None
),
)

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.

🔴 Keyed creates fail after dropped connections

When a connection drops after a keyed create is sent, the create fails without retrying. isRetryableFetchError excludes post-write errors, and RetryableTransport propagates them. The server can create the sandbox while the caller receives an error.

Learn more

A keyed create passes its header to the sync and async API clients, but the retry layer never examines it. isRetryableFetchError only accepts connection-establishment failures; RetryableTransport and AsyncRetryableTransport do not catch post-write transport errors. Once the server receives a create, a dropped response can leave the client unaware of a successfully created sandbox.

Example: A client sends POST /v2/sandboxes with key job42; the server creates it and the response connection resets. The call raises rather than replaying job42 to retrieve the result.

Recommended fix: Restore header-and-route-aware post-write network retries in the JS fetch wrapper and both Python retry transports. Reuse the same request body and key, exclude aborts and unkeyed creates, and cover the error path in tests.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +255 to +262
api_client = get_api_client(
config,
headers=(
{IDEMPOTENCY_KEY_HEADER: config.idempotency_key}
if config.idempotency_key is not None
else None
),
)

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.

🟡 Concurrent keyed creates return transient conflicts

If a keyed create receives an unmarked idempotency_in_progress 409, it fails immediately. The Python retry gate and JS retry gate require Should-Retry. The second caller never receives the first create's result.

Learn more

A concurrent keyed create can receive HTTP 409 with error_code: idempotency_in_progress while the first create is still running. The key is sent to the server, but the Python retry gate and JS retry gate only consider Should-Retry: true. Without that response header, no attempt waits for completion, even when the response includes Retry-After.

Example: Two workers create with key job42. One request starts creation; the other receives an unmarked in-progress 409 and raises rather than waiting to obtain the same sandbox.

Recommended fix: For keyed POST /v2/sandboxes, recognize only the in-progress 409 error code in both SDKs and retry within the existing budget; leave mismatch and unknown-outcome errors terminal. Add sync, async, and JS coverage.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

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

This branch has not been deployed

No deployments
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