From 20cfdbf47dfa41c3ef877b104c54ef1b9dc29a95 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:41:03 +0000 Subject: [PATCH 1/6] feat(sdk): add idempotency key connection option for sandbox create Co-Authored-By: mish@e2b.dev --- .changeset/sandbox-create-idempotency.md | 7 ++ packages/js-sdk/src/connectionConfig.ts | 10 +- packages/js-sdk/src/retry.ts | 14 +++ packages/js-sdk/src/sandbox/sandboxApi.ts | 5 + .../js-sdk/tests/sandbox/idempotency.test.ts | 77 +++++++++++++ packages/python-sdk/e2b/connection_config.py | 15 ++- packages/python-sdk/e2b/retry.py | 15 +++ .../e2b/sandbox_async/sandbox_api.py | 10 +- .../e2b/sandbox_sync/sandbox_api.py | 10 +- .../tests/test_connection_config.py | 36 +++++++ .../tests/test_sandbox_create_idempotency.py | 102 ++++++++++++++++++ 11 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 .changeset/sandbox-create-idempotency.md create mode 100644 packages/js-sdk/tests/sandbox/idempotency.test.ts create mode 100644 packages/python-sdk/tests/test_sandbox_create_idempotency.py diff --git a/.changeset/sandbox-create-idempotency.md b/.changeset/sandbox-create-idempotency.md new file mode 100644 index 0000000000..fa7224e9e3 --- /dev/null +++ b/.changeset/sandbox-create-idempotency.md @@ -0,0 +1,7 @@ +--- +'e2b': minor +'@e2b/python-sdk': minor +--- + +- Added `idempotencyKey` (JavaScript) and `idempotency_key` (Python) connection + options that send `Idempotency-Key` on sandbox creation (1-255 characters). diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index 0cb5ea3ba1..e1f2612c68 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -1,7 +1,7 @@ import { Logger } from './logs' import { getEnvVar, version } from './api/metadata' import { runtime } from './utils' -import { resolveRetries } from './retry' +import { resolveIdempotencyKey, resolveRetries } from './retry' import { InvalidArgumentError } from './errors' // Remove once all deployments support sandbox subdomains @@ -56,6 +56,12 @@ export interface ConnectionOpts { * @default E2B_API_KEY // environment variable */ apiKey?: string + /** + * Idempotency key sent as the `Idempotency-Key` header when creating a + * sandbox. Retries reuse the key. Keys outside 1–255 characters throw + * `InvalidArgumentError`. + */ + idempotencyKey?: string /** * Whether to validate the format of the E2B API key on the client side. * @@ -462,6 +468,7 @@ export class ConnectionConfig { readonly requestTimeoutMs: number readonly retries: number + readonly idempotencyKey?: string readonly apiKey?: string /** * @deprecated The API key format is no longer validated on the client side; @@ -476,6 +483,7 @@ export class ConnectionConfig { constructor(opts?: ConnectionOpts) { this.apiKey = opts?.apiKey || ConnectionConfig.apiKey + this.idempotencyKey = resolveIdempotencyKey(opts?.idempotencyKey) this.validateApiKey = opts?.validateApiKey this.debug = opts?.debug ?? ConnectionConfig.debug this.domain = opts?.domain || ConnectionConfig.domain diff --git a/packages/js-sdk/src/retry.ts b/packages/js-sdk/src/retry.ts index ead92931ee..acbc8c50d3 100644 --- a/packages/js-sdk/src/retry.ts +++ b/packages/js-sdk/src/retry.ts @@ -1,6 +1,8 @@ import { InvalidArgumentError } from './errors' import { isReadableStreamLike } from './is' +export const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key' + // Retries API responses marked `Should-Retry: true`, or selected statuses // when the header is absent. const MAX_RETRY_AFTER_SECONDS = 2_147_483 @@ -41,6 +43,18 @@ export function resolveRetries(retries: number): number { return retries } +export function resolveIdempotencyKey( + key: string | undefined +): string | undefined { + if (key === undefined) return undefined + if (key.length < 1 || key.length > 255) { + throw new InvalidArgumentError( + `Invalid idempotencyKey: expected 1-255 characters, got ${key.length}.` + ) + } + return key +} + export function parseRetryAfter( value: string | null | undefined ): number | undefined { diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index cc2df50a93..f2db87cbc7 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -16,6 +16,7 @@ import { } from '../errors' import { Paginator } from '../paginator' import { timeoutToSeconds } from '../utils' +import { IDEMPOTENCY_KEY_HEADER } from '../retry' import type { Volume } from '../volume' import type { McpServer as BaseMcpServer } from './mcp' @@ -1976,6 +1977,10 @@ export class SandboxApi extends ClientFactory { const client = new ApiClient(config) const res = await client.api.POST('/v2/sandboxes', { body, + headers: + config.idempotencyKey !== undefined + ? { [IDEMPOTENCY_KEY_HEADER]: config.idempotencyKey } + : undefined, signal: config.getSignal(apiOpts?.requestTimeoutMs, apiOpts?.signal), }) diff --git a/packages/js-sdk/tests/sandbox/idempotency.test.ts b/packages/js-sdk/tests/sandbox/idempotency.test.ts new file mode 100644 index 0000000000..7aa23b5545 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/idempotency.test.ts @@ -0,0 +1,77 @@ +import { afterAll, afterEach, beforeAll, expect, test } from 'vitest' +import { http, HttpResponse } from 'msw' + +import { E2B, Sandbox } from '../../src' +import { ConnectionConfig } from '../../src/connectionConfig' +import { InvalidArgumentError } from '../../src/errors' +import { TEST_API_KEY, apiUrl } from '../setup' +import { setupMockApi } from '../mockApi' + +const idempotencyKeys: (string | null)[] = [] + +const server = setupMockApi( + http.post(apiUrl('/v2/sandboxes'), ({ request }) => { + idempotencyKeys.push(request.headers.get('Idempotency-Key')) + return HttpResponse.json({ + sandboxID: 'test-sandbox-id', + templateID: 'base', + envdVersion: '0.2.4', + }) + }) +) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + +afterAll(() => server.close()) + +afterEach(() => { + idempotencyKeys.length = 0 + server.resetHandlers() +}) + +test('Sandbox.create sends the configured idempotency key', async () => { + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + idempotencyKey: 'abc123', + }) + + expect(idempotencyKeys).toEqual(['abc123']) +}) + +test('Sandbox.create does not send an idempotency key when unset', async () => { + await Sandbox.create('base', { apiKey: TEST_API_KEY }) + + expect(idempotencyKeys).toEqual([null]) +}) + +test('ConnectionConfig accepts a 255-character idempotency key', () => { + const key = 'a'.repeat(255) + + expect(new ConnectionConfig({ idempotencyKey: key }).idempotencyKey).toBe(key) +}) + +test('ConnectionConfig rejects idempotency keys outside 1-255 characters', () => { + const emptyKey = () => new ConnectionConfig({ idempotencyKey: '' }) + const longKey = () => + new ConnectionConfig({ idempotencyKey: 'a'.repeat(256) }) + + expect(emptyKey).toThrow(InvalidArgumentError) + expect(emptyKey).toThrow( + 'Invalid idempotencyKey: expected 1-255 characters, got 0.' + ) + expect(longKey).toThrow(InvalidArgumentError) + expect(longKey).toThrow( + 'Invalid idempotencyKey: expected 1-255 characters, got 256.' + ) +}) + +test('client.Sandbox.create uses the bound idempotency key', async () => { + const client = new E2B({ + apiKey: TEST_API_KEY, + idempotencyKey: 'bound123', + }) + + await client.Sandbox.create('base') + + expect(idempotencyKeys).toEqual(['bound123']) +}) diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index c818d12317..dbed00fd10 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -15,7 +15,7 @@ import httpx from e2b.exceptions import InvalidArgumentException -from e2b.retry import resolve_max_retries +from e2b.retry import resolve_idempotency_key, resolve_max_retries from e2b.api.metadata import package_version from e2b.sandbox_domains import is_supported_sandbox_domain @@ -99,6 +99,11 @@ class ApiParams(TypedDict, total=False): api_key: Optional[str] """E2B API Key to use for authentication, defaults to `E2B_API_KEY` environment variable.""" + idempotency_key: Optional[str] + """Idempotency key sent as the ``Idempotency-Key`` header when creating a + sandbox. Retries reuse the key. Keys outside 1–255 characters raise + ``InvalidArgumentException``.""" + validate_api_key: Optional[bool] """Deprecated: the API key format is no longer validated on the client side; this option has no effect.""" @@ -284,11 +289,13 @@ def __init__( *, retries: Optional[int] = None, http_version: Optional[HttpVersion] = None, + idempotency_key: Optional[str] = None, ): self.logger = logger self.domain = domain or ConnectionConfig._domain() self.debug = debug if debug is not None else ConnectionConfig._debug() self.api_key = api_key or ConnectionConfig._api_key() + self.idempotency_key = resolve_idempotency_key(idempotency_key) self.validate_api_key = validate_api_key self.headers = {**(headers or {}), **(api_headers or {})} self._user_agent_is_sdk_built = self._apply_user_agent( @@ -405,6 +412,7 @@ def get_api_params( sandbox_url = opts.get("sandbox_url") http_version = opts.get("http_version") retries = opts.get("retries") + idempotency_key = opts.get("idempotency_key") req_headers = self.headers.copy() if headers is not None: @@ -448,6 +456,11 @@ def get_api_params( ), logger=self.logger, retries=retries if retries is not None else self.retries, + idempotency_key=( + idempotency_key + if idempotency_key is not None + else self.idempotency_key + ), ) ) diff --git a/packages/python-sdk/e2b/retry.py b/packages/python-sdk/e2b/retry.py index d1b5985f5f..b3359281ec 100644 --- a/packages/python-sdk/e2b/retry.py +++ b/packages/python-sdk/e2b/retry.py @@ -15,6 +15,7 @@ BACKOFF_MAX_SECONDS = 10.0 BACKOFF_JITTER_MIN = 0.5 RETRYABLE_STATUSES = frozenset({429, 502, 503, 504}) +IDEMPOTENCY_KEY_HEADER = "Idempotency-Key" def resolve_max_retries(retries: int) -> int: @@ -25,6 +26,20 @@ def resolve_max_retries(retries: int) -> int: return retries +def resolve_idempotency_key(key: Optional[str]) -> Optional[str]: + if key is None: + return None + if not isinstance(key, str): + raise InvalidArgumentException( + f"Invalid idempotency_key: expected a string, got {type(key).__name__}." + ) + if len(key) < 1 or len(key) > 255: + raise InvalidArgumentException( + f"Invalid idempotency_key: expected 1-255 characters, got {len(key)}." + ) + return key + + def parse_retry_after(value: Optional[str]) -> Optional[int]: if not value: return None diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index ec30eb6d7a..8505d3b575 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -42,6 +42,7 @@ SandboxNotFoundException, TemplateException, ) +from e2b.retry import IDEMPOTENCY_KEY_HEADER from e2b.sandbox.main import SandboxBase from e2b.sandbox.sandbox_api import ( build_network_update_body, @@ -249,7 +250,14 @@ async def _create_sandbox( volume_mounts=volume_mounts if volume_mounts else UNSET, ) - api_client = get_api_client(config) + api_client = get_api_client( + config, + headers=( + {IDEMPOTENCY_KEY_HEADER: config.idempotency_key} + if config.idempotency_key is not None + else None + ), + ) res = await post_v2_sandboxes.asyncio_detailed( body=body, client=api_client, diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index 27e658e393..040c2aabcb 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -41,6 +41,7 @@ SandboxNotFoundException, TemplateException, ) +from e2b.retry import IDEMPOTENCY_KEY_HEADER from e2b.sandbox.main import SandboxBase from e2b.sandbox.sandbox_api import ( build_network_update_body, @@ -248,7 +249,14 @@ def _create_sandbox( volume_mounts=volume_mounts if volume_mounts else UNSET, ) - api_client = get_api_client(config) + api_client = get_api_client( + config, + headers=( + {IDEMPOTENCY_KEY_HEADER: config.idempotency_key} + if config.idempotency_key is not None + else None + ), + ) res = post_v2_sandboxes.sync_detailed( body=body, client=api_client, diff --git a/packages/python-sdk/tests/test_connection_config.py b/packages/python-sdk/tests/test_connection_config.py index b9ff76c157..3063705950 100644 --- a/packages/python-sdk/tests/test_connection_config.py +++ b/packages/python-sdk/tests/test_connection_config.py @@ -5,6 +5,42 @@ from e2b.exceptions import InvalidArgumentException +def test_idempotency_key_accepts_255_characters(): + key = "a" * 255 + + assert ConnectionConfig(idempotency_key=key).idempotency_key == key + + +@pytest.mark.parametrize( + ("key", "length"), + [("", 0), ("a" * 256, 256)], + ids=["empty", "too-long"], +) +def test_idempotency_key_rejects_invalid_lengths(key, length): + with pytest.raises( + InvalidArgumentException, + match=f"Invalid idempotency_key: expected 1-255 characters, got {length}.", + ): + ConnectionConfig(idempotency_key=key) + + +def test_idempotency_key_rejects_non_strings(): + with pytest.raises( + InvalidArgumentException, + match="Invalid idempotency_key: expected a string, got int.", + ): + ConnectionConfig(idempotency_key=123) + + +def test_idempotency_key_is_stored_and_propagates(): + config = ConnectionConfig(idempotency_key="abc123") + + assert config.idempotency_key == "abc123" + assert config.get_api_params()["idempotency_key"] == "abc123" + assert ConnectionConfig(**config.get_api_params()).idempotency_key == "abc123" + assert config.get_api_params(idempotency_key="other")["idempotency_key"] == "other" + + def test_api_url_defaults_correctly(monkeypatch): monkeypatch.setenv("E2B_DOMAIN", "") monkeypatch.delenv("E2B_API_URL", raising=False) diff --git a/packages/python-sdk/tests/test_sandbox_create_idempotency.py b/packages/python-sdk/tests/test_sandbox_create_idempotency.py new file mode 100644 index 0000000000..7b055fc1c1 --- /dev/null +++ b/packages/python-sdk/tests/test_sandbox_create_idempotency.py @@ -0,0 +1,102 @@ +from types import SimpleNamespace + +import pytest + +from e2b.retry import IDEMPOTENCY_KEY_HEADER +from e2b.sandbox_async import sandbox_api as async_sandbox_api +from e2b.sandbox_sync import sandbox_api as sync_sandbox_api + + +def _response(): + return SimpleNamespace( + status_code=201, + parsed=SimpleNamespace( + sandbox_id="sandbox-id", + domain=None, + envd_version="0.2.4", + envd_access_token=None, + traffic_access_token=None, + ), + ) + + +def test_sync_create_sends_configured_idempotency_key(monkeypatch): + headers = [] + + def get_api_client(config, **kwargs): + headers.append(kwargs.get("headers")) + return object() + + monkeypatch.setattr(sync_sandbox_api, "get_api_client", get_api_client) + monkeypatch.setattr( + sync_sandbox_api.post_v2_sandboxes, + "sync_detailed", + lambda **kwargs: _response(), + ) + + sync_sandbox_api.SandboxApi._create_sandbox( + "base", None, None, None, None, None, idempotency_key="abc123" + ) + + assert headers == [{IDEMPOTENCY_KEY_HEADER: "abc123"}] + + +@pytest.mark.asyncio +async def test_async_create_sends_configured_idempotency_key(monkeypatch): + headers = [] + + def get_api_client(config, **kwargs): + headers.append(kwargs.get("headers")) + return object() + + async def create(**kwargs): + return _response() + + monkeypatch.setattr(async_sandbox_api, "get_api_client", get_api_client) + monkeypatch.setattr(async_sandbox_api.post_v2_sandboxes, "asyncio_detailed", create) + + await async_sandbox_api.SandboxApi._create_sandbox( + "base", None, None, None, None, None, idempotency_key="abc123" + ) + + assert headers == [{IDEMPOTENCY_KEY_HEADER: "abc123"}] + + +def test_sync_create_does_not_send_idempotency_key_when_unset(monkeypatch): + headers = [] + + def get_api_client(config, **kwargs): + headers.append(kwargs.get("headers")) + return object() + + monkeypatch.setattr(sync_sandbox_api, "get_api_client", get_api_client) + monkeypatch.setattr( + sync_sandbox_api.post_v2_sandboxes, + "sync_detailed", + lambda **kwargs: _response(), + ) + + sync_sandbox_api.SandboxApi._create_sandbox("base", None, None, None, None, None) + + assert headers == [None] + + +@pytest.mark.asyncio +async def test_async_create_does_not_send_idempotency_key_when_unset(monkeypatch): + headers = [] + + def get_api_client(config, **kwargs): + headers.append(kwargs.get("headers")) + return object() + + async def create(**kwargs): + return _response() + + monkeypatch.setattr(async_sandbox_api, "get_api_client", get_api_client) + monkeypatch.setattr(async_sandbox_api.post_v2_sandboxes, "asyncio_detailed", create) + + await async_sandbox_api.SandboxApi._create_sandbox( + "base", None, None, None, None, None + ) + + assert headers == [None] From 6a819e4ce093b205b51f3927a7df677f0725e996 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:59:01 +0000 Subject: [PATCH 2/6] test(python): cast invalid idempotency key test value Co-Authored-By: mish@e2b.dev --- packages/python-sdk/tests/test_connection_config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/python-sdk/tests/test_connection_config.py b/packages/python-sdk/tests/test_connection_config.py index 3063705950..e4eb58114f 100644 --- a/packages/python-sdk/tests/test_connection_config.py +++ b/packages/python-sdk/tests/test_connection_config.py @@ -1,3 +1,5 @@ +from typing import cast + import pytest from e2b import ConnectionConfig @@ -29,7 +31,7 @@ def test_idempotency_key_rejects_non_strings(): InvalidArgumentException, match="Invalid idempotency_key: expected a string, got int.", ): - ConnectionConfig(idempotency_key=123) + ConnectionConfig(idempotency_key=cast(str, 123)) def test_idempotency_key_is_stored_and_propagates(): From 95602ac922d7fce457c6d84ce9b8e1a21cb6727a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:46:34 +0000 Subject: [PATCH 3/6] feat(sdk): validate idempotency key against the API pattern Co-Authored-By: mish@e2b.dev --- .changeset/sandbox-create-idempotency.md | 3 +- packages/js-sdk/src/connectionConfig.ts | 3 +- packages/js-sdk/src/retry.ts | 9 +++- packages/js-sdk/src/sandbox/sandboxApi.ts | 5 +- .../js-sdk/tests/sandbox/idempotency.test.ts | 15 ++++++ packages/python-sdk/e2b/connection_config.py | 3 +- packages/python-sdk/e2b/retry.py | 6 ++- .../e2b/sandbox_async/sandbox_api.py | 13 ++--- .../e2b/sandbox_sync/sandbox_api.py | 13 ++--- .../tests/test_connection_config.py | 15 ++++++ .../tests/test_sandbox_create_idempotency.py | 52 +++++++++---------- 11 files changed, 83 insertions(+), 54 deletions(-) diff --git a/.changeset/sandbox-create-idempotency.md b/.changeset/sandbox-create-idempotency.md index fa7224e9e3..3409958432 100644 --- a/.changeset/sandbox-create-idempotency.md +++ b/.changeset/sandbox-create-idempotency.md @@ -4,4 +4,5 @@ --- - Added `idempotencyKey` (JavaScript) and `idempotency_key` (Python) connection - options that send `Idempotency-Key` on sandbox creation (1-255 characters). + options that send `Idempotency-Key` on sandbox creation (1-255 lowercase + letters, digits or hyphens). diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index e1f2612c68..77547f3ba1 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -58,7 +58,8 @@ export interface ConnectionOpts { apiKey?: string /** * Idempotency key sent as the `Idempotency-Key` header when creating a - * sandbox. Retries reuse the key. Keys outside 1–255 characters throw + * sandbox. Retries reuse the key. Keys must contain 1–255 lowercase ASCII + * letters, digits or hyphens (e.g. a lowercase UUID); other keys throw * `InvalidArgumentError`. */ idempotencyKey?: string diff --git a/packages/js-sdk/src/retry.ts b/packages/js-sdk/src/retry.ts index acbc8c50d3..d9b88bac8c 100644 --- a/packages/js-sdk/src/retry.ts +++ b/packages/js-sdk/src/retry.ts @@ -1,8 +1,6 @@ import { InvalidArgumentError } from './errors' import { isReadableStreamLike } from './is' -export const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key' - // Retries API responses marked `Should-Retry: true`, or selected statuses // when the header is absent. const MAX_RETRY_AFTER_SECONDS = 2_147_483 @@ -43,6 +41,8 @@ export function resolveRetries(retries: number): number { return retries } +const IDEMPOTENCY_KEY_PATTERN = /^[a-z0-9-]{1,255}$/ + export function resolveIdempotencyKey( key: string | undefined ): string | undefined { @@ -52,6 +52,11 @@ export function resolveIdempotencyKey( `Invalid idempotencyKey: expected 1-255 characters, got ${key.length}.` ) } + if (!IDEMPOTENCY_KEY_PATTERN.test(key)) { + throw new InvalidArgumentError( + 'Invalid idempotencyKey: only lowercase letters, digits and hyphens are allowed.' + ) + } return key } diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index f2db87cbc7..c29048ba20 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -16,7 +16,6 @@ import { } from '../errors' import { Paginator } from '../paginator' import { timeoutToSeconds } from '../utils' -import { IDEMPOTENCY_KEY_HEADER } from '../retry' import type { Volume } from '../volume' import type { McpServer as BaseMcpServer } from './mcp' @@ -1977,9 +1976,9 @@ export class SandboxApi extends ClientFactory { const client = new ApiClient(config) const res = await client.api.POST('/v2/sandboxes', { body, - headers: + params: config.idempotencyKey !== undefined - ? { [IDEMPOTENCY_KEY_HEADER]: config.idempotencyKey } + ? { header: { 'Idempotency-Key': config.idempotencyKey } } : undefined, signal: config.getSignal(apiOpts?.requestTimeoutMs, apiOpts?.signal), }) diff --git a/packages/js-sdk/tests/sandbox/idempotency.test.ts b/packages/js-sdk/tests/sandbox/idempotency.test.ts index 7aa23b5545..3de7392405 100644 --- a/packages/js-sdk/tests/sandbox/idempotency.test.ts +++ b/packages/js-sdk/tests/sandbox/idempotency.test.ts @@ -50,6 +50,12 @@ test('ConnectionConfig accepts a 255-character idempotency key', () => { expect(new ConnectionConfig({ idempotencyKey: key }).idempotencyKey).toBe(key) }) +test('ConnectionConfig accepts a lowercase UUID idempotency key', () => { + const key = '123e4567-e89b-12d3-a456-426614174000' + + expect(new ConnectionConfig({ idempotencyKey: key }).idempotencyKey).toBe(key) +}) + test('ConnectionConfig rejects idempotency keys outside 1-255 characters', () => { const emptyKey = () => new ConnectionConfig({ idempotencyKey: '' }) const longKey = () => @@ -65,6 +71,15 @@ test('ConnectionConfig rejects idempotency keys outside 1-255 characters', () => ) }) +test.each(['ABC', 'abc_1', ' abc ', '界'])( + 'ConnectionConfig rejects invalid idempotency key characters: %s', + (key) => { + expect(() => new ConnectionConfig({ idempotencyKey: key })).toThrow( + 'Invalid idempotencyKey: only lowercase letters, digits and hyphens are allowed.' + ) + } +) + test('client.Sandbox.create uses the bound idempotency key', async () => { const client = new E2B({ apiKey: TEST_API_KEY, diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index dbed00fd10..f80dafda76 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -101,7 +101,8 @@ class ApiParams(TypedDict, total=False): idempotency_key: Optional[str] """Idempotency key sent as the ``Idempotency-Key`` header when creating a - sandbox. Retries reuse the key. Keys outside 1–255 characters raise + sandbox. Retries reuse the key. Keys must contain 1–255 lowercase ASCII + letters, digits or hyphens (e.g. a lowercase UUID); other keys raise ``InvalidArgumentException``.""" validate_api_key: Optional[bool] diff --git a/packages/python-sdk/e2b/retry.py b/packages/python-sdk/e2b/retry.py index b3359281ec..8f7a40cfd0 100644 --- a/packages/python-sdk/e2b/retry.py +++ b/packages/python-sdk/e2b/retry.py @@ -2,6 +2,7 @@ import asyncio import random +import re import time from typing import Awaitable, Callable, Optional @@ -15,7 +16,6 @@ BACKOFF_MAX_SECONDS = 10.0 BACKOFF_JITTER_MIN = 0.5 RETRYABLE_STATUSES = frozenset({429, 502, 503, 504}) -IDEMPOTENCY_KEY_HEADER = "Idempotency-Key" def resolve_max_retries(retries: int) -> int: @@ -37,6 +37,10 @@ def resolve_idempotency_key(key: Optional[str]) -> Optional[str]: raise InvalidArgumentException( f"Invalid idempotency_key: expected 1-255 characters, got {len(key)}." ) + if re.fullmatch(r"[a-z0-9-]+", key) is None: + raise InvalidArgumentException( + "Invalid idempotency_key: only lowercase letters, digits and hyphens are allowed." + ) return key diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index 8505d3b575..d40bdc852f 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -42,7 +42,6 @@ SandboxNotFoundException, TemplateException, ) -from e2b.retry import IDEMPOTENCY_KEY_HEADER from e2b.sandbox.main import SandboxBase from e2b.sandbox.sandbox_api import ( build_network_update_body, @@ -250,17 +249,13 @@ async def _create_sandbox( volume_mounts=volume_mounts if volume_mounts else UNSET, ) - api_client = get_api_client( - config, - headers=( - {IDEMPOTENCY_KEY_HEADER: config.idempotency_key} - if config.idempotency_key is not None - else None - ), - ) + api_client = get_api_client(config) res = await post_v2_sandboxes.asyncio_detailed( body=body, client=api_client, + idempotency_key=( + config.idempotency_key if config.idempotency_key is not None else UNSET + ), ) if res.status_code >= 300: diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index 040c2aabcb..0ce34ccc54 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -41,7 +41,6 @@ SandboxNotFoundException, TemplateException, ) -from e2b.retry import IDEMPOTENCY_KEY_HEADER from e2b.sandbox.main import SandboxBase from e2b.sandbox.sandbox_api import ( build_network_update_body, @@ -249,17 +248,13 @@ def _create_sandbox( volume_mounts=volume_mounts if volume_mounts else UNSET, ) - api_client = get_api_client( - config, - headers=( - {IDEMPOTENCY_KEY_HEADER: config.idempotency_key} - if config.idempotency_key is not None - else None - ), - ) + api_client = get_api_client(config) res = post_v2_sandboxes.sync_detailed( body=body, client=api_client, + idempotency_key=( + config.idempotency_key if config.idempotency_key is not None else UNSET + ), ) if res.status_code >= 300: diff --git a/packages/python-sdk/tests/test_connection_config.py b/packages/python-sdk/tests/test_connection_config.py index e4eb58114f..d29acad62f 100644 --- a/packages/python-sdk/tests/test_connection_config.py +++ b/packages/python-sdk/tests/test_connection_config.py @@ -13,6 +13,12 @@ def test_idempotency_key_accepts_255_characters(): assert ConnectionConfig(idempotency_key=key).idempotency_key == key +def test_idempotency_key_accepts_lowercase_uuid(): + key = "123e4567-e89b-12d3-a456-426614174000" + + assert ConnectionConfig(idempotency_key=key).idempotency_key == key + + @pytest.mark.parametrize( ("key", "length"), [("", 0), ("a" * 256, 256)], @@ -26,6 +32,15 @@ def test_idempotency_key_rejects_invalid_lengths(key, length): ConnectionConfig(idempotency_key=key) +@pytest.mark.parametrize("key", ["ABC", "abc_1", " abc ", "界"]) +def test_idempotency_key_rejects_invalid_characters(key): + with pytest.raises( + InvalidArgumentException, + match="Invalid idempotency_key: only lowercase letters, digits and hyphens are allowed.", + ): + ConnectionConfig(idempotency_key=key) + + def test_idempotency_key_rejects_non_strings(): with pytest.raises( InvalidArgumentException, diff --git a/packages/python-sdk/tests/test_sandbox_create_idempotency.py b/packages/python-sdk/tests/test_sandbox_create_idempotency.py index 7b055fc1c1..116678d91e 100644 --- a/packages/python-sdk/tests/test_sandbox_create_idempotency.py +++ b/packages/python-sdk/tests/test_sandbox_create_idempotency.py @@ -2,7 +2,7 @@ import pytest -from e2b.retry import IDEMPOTENCY_KEY_HEADER +from e2b.api.client.types import UNSET from e2b.sandbox_async import sandbox_api as async_sandbox_api from e2b.sandbox_sync import sandbox_api as sync_sandbox_api @@ -21,35 +21,34 @@ def _response(): def test_sync_create_sends_configured_idempotency_key(monkeypatch): - headers = [] + idempotency_keys = [] - def get_api_client(config, **kwargs): - headers.append(kwargs.get("headers")) + def get_api_client(config): return object() + def create(**kwargs): + idempotency_keys.append(kwargs["idempotency_key"]) + return _response() + monkeypatch.setattr(sync_sandbox_api, "get_api_client", get_api_client) - monkeypatch.setattr( - sync_sandbox_api.post_v2_sandboxes, - "sync_detailed", - lambda **kwargs: _response(), - ) + monkeypatch.setattr(sync_sandbox_api.post_v2_sandboxes, "sync_detailed", create) sync_sandbox_api.SandboxApi._create_sandbox( "base", None, None, None, None, None, idempotency_key="abc123" ) - assert headers == [{IDEMPOTENCY_KEY_HEADER: "abc123"}] + assert idempotency_keys == ["abc123"] @pytest.mark.asyncio async def test_async_create_sends_configured_idempotency_key(monkeypatch): - headers = [] + idempotency_keys = [] - def get_api_client(config, **kwargs): - headers.append(kwargs.get("headers")) + def get_api_client(config): return object() async def create(**kwargs): + idempotency_keys.append(kwargs["idempotency_key"]) return _response() monkeypatch.setattr(async_sandbox_api, "get_api_client", get_api_client) @@ -59,37 +58,36 @@ async def create(**kwargs): "base", None, None, None, None, None, idempotency_key="abc123" ) - assert headers == [{IDEMPOTENCY_KEY_HEADER: "abc123"}] + assert idempotency_keys == ["abc123"] def test_sync_create_does_not_send_idempotency_key_when_unset(monkeypatch): - headers = [] + idempotency_keys = [] - def get_api_client(config, **kwargs): - headers.append(kwargs.get("headers")) + def get_api_client(config): return object() + def create(**kwargs): + idempotency_keys.append(kwargs["idempotency_key"]) + return _response() + monkeypatch.setattr(sync_sandbox_api, "get_api_client", get_api_client) - monkeypatch.setattr( - sync_sandbox_api.post_v2_sandboxes, - "sync_detailed", - lambda **kwargs: _response(), - ) + monkeypatch.setattr(sync_sandbox_api.post_v2_sandboxes, "sync_detailed", create) sync_sandbox_api.SandboxApi._create_sandbox("base", None, None, None, None, None) - assert headers == [None] + assert idempotency_keys == [UNSET] @pytest.mark.asyncio async def test_async_create_does_not_send_idempotency_key_when_unset(monkeypatch): - headers = [] + idempotency_keys = [] - def get_api_client(config, **kwargs): - headers.append(kwargs.get("headers")) + def get_api_client(config): return object() async def create(**kwargs): + idempotency_keys.append(kwargs["idempotency_key"]) return _response() monkeypatch.setattr(async_sandbox_api, "get_api_client", get_api_client) @@ -99,4 +97,4 @@ async def create(**kwargs): "base", None, None, None, None, None ) - assert headers == [None] + assert idempotency_keys == [UNSET] From a86bf49d934f40ccfbf43a38371058458a40de2f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:55:38 +0000 Subject: [PATCH 4/6] fix(sdk): scope idempotency keys to sandbox creation Co-Authored-By: mish@e2b.dev --- packages/js-sdk/src/client.ts | 14 ++++-- .../js-sdk/tests/sandbox/idempotency.test.ts | 23 ++++++--- packages/python-sdk/e2b/client.py | 15 +++++- .../tests/test_sandbox_create_idempotency.py | 50 +++++++++++++++++++ 4 files changed, 91 insertions(+), 11 deletions(-) diff --git a/packages/js-sdk/src/client.ts b/packages/js-sdk/src/client.ts index 3ae82dcec5..de28224237 100644 --- a/packages/js-sdk/src/client.ts +++ b/packages/js-sdk/src/client.ts @@ -1,4 +1,5 @@ import { ConnectionOpts } from './connectionConfig' +import { InvalidArgumentError } from './errors' import { Sandbox } from './sandbox' import { Secret } from './secret' import { Template, TemplateBase } from './template' @@ -8,10 +9,11 @@ import { Volume } from './volume' /** * Connection options bound to an {@link E2B} client. * - * Same as {@link ConnectionOpts} without `signal`, which cancels a single - * request and therefore can only be passed per call. + * Same as {@link ConnectionOpts} without `signal` or `idempotencyKey`. + * `signal` cancels a single request, and `idempotencyKey` is scoped to one + * sandbox creation, so both must be passed per call. */ -export type E2BClientOpts = Omit +export type E2BClientOpts = Omit /** * E2B client with an explicitly bound connection configuration. @@ -68,6 +70,12 @@ export class E2B { * through this client's resource classes. */ constructor(opts?: E2BClientOpts) { + if ((opts as ConnectionOpts | undefined)?.idempotencyKey !== undefined) { + throw new InvalidArgumentError( + 'idempotencyKey is per sandbox create; pass it to Sandbox.create instead of the E2B client.' + ) + } + // Options are copied so later mutations of the caller's object cannot // change the bound configuration. `signal` is dropped rather than only // typed away, since it cancels a single request and a caller passing a diff --git a/packages/js-sdk/tests/sandbox/idempotency.test.ts b/packages/js-sdk/tests/sandbox/idempotency.test.ts index 3de7392405..688a27cfe5 100644 --- a/packages/js-sdk/tests/sandbox/idempotency.test.ts +++ b/packages/js-sdk/tests/sandbox/idempotency.test.ts @@ -80,13 +80,24 @@ test.each(['ABC', 'abc_1', ' abc ', '界'])( } ) -test('client.Sandbox.create uses the bound idempotency key', async () => { - const client = new E2B({ - apiKey: TEST_API_KEY, - idempotencyKey: 'bound123', - }) +test('E2B rejects a client-bound idempotency key', () => { + const createClientWithKey = () => + new E2B({ + apiKey: TEST_API_KEY, + idempotencyKey: 'bound123', + } as any) + + expect(createClientWithKey).toThrow(InvalidArgumentError) + expect(createClientWithKey).toThrow( + 'idempotencyKey is per sandbox create; pass it to Sandbox.create instead of the E2B client.' + ) +}) + +test('client.Sandbox.create accepts a per-call idempotency key', async () => { + const client = new E2B({ apiKey: TEST_API_KEY }) + await client.Sandbox.create('base', { idempotencyKey: 'abc123' }) await client.Sandbox.create('base') - expect(idempotencyKeys).toEqual(['bound123']) + expect(idempotencyKeys).toEqual(['abc123', null]) }) diff --git a/packages/python-sdk/e2b/client.py b/packages/python-sdk/e2b/client.py index d704e48160..0c31556314 100644 --- a/packages/python-sdk/e2b/client.py +++ b/packages/python-sdk/e2b/client.py @@ -1,6 +1,7 @@ from typing import Dict, Type, TypeVar, Unpack, cast from e2b.connection_config import ApiParams +from e2b.exceptions import InvalidArgumentException from e2b.sandbox_async.main import AsyncSandbox from e2b.sandbox_sync.main import Sandbox from e2b.secret import AsyncSecret, Secret @@ -14,7 +15,8 @@ class E2BClientParams(ApiParams, total=False): """Params bound to an :class:`E2B` client, used as the defaults for every - call made through its resource classes. Same shape as :class:`ApiParams`.""" + call made through its resource classes. Same shape as :class:`ApiParams`, + except ``idempotency_key`` must be passed per sandbox creation.""" def _bind(cls: Type[T], api_params: ApiParams) -> Type[T]: @@ -39,6 +41,9 @@ class E2B: Multiple clients are fully isolated from each other and from the top-level env-configured exports. + ``idempotency_key`` is scoped to one sandbox creation and must be passed to + ``Sandbox.create`` or ``AsyncSandbox.create``, not bound to the client. + Example: ```python from e2b import E2B @@ -56,8 +61,14 @@ def __init__(self, **opts: Unpack[E2BClientParams]): Create a new client with the API params bound to it. :param opts: API params used as the defaults for every call made - through this client's resource classes. + through this client's resource classes. ``idempotency_key`` must + be passed per sandbox creation instead of bound to the client. """ + if opts.get("idempotency_key") is not None: + raise InvalidArgumentException( + "idempotency_key is per sandbox create; pass it to Sandbox.create instead of the E2B client." + ) + # Params are copied so later mutations of the caller's dicts cannot # change the bound configuration. api_params = cast(ApiParams, dict(cast(Dict[str, object], opts))) diff --git a/packages/python-sdk/tests/test_sandbox_create_idempotency.py b/packages/python-sdk/tests/test_sandbox_create_idempotency.py index 116678d91e..5452c2654a 100644 --- a/packages/python-sdk/tests/test_sandbox_create_idempotency.py +++ b/packages/python-sdk/tests/test_sandbox_create_idempotency.py @@ -3,6 +3,8 @@ import pytest from e2b.api.client.types import UNSET +from e2b.client import E2B +from e2b.exceptions import InvalidArgumentException from e2b.sandbox_async import sandbox_api as async_sandbox_api from e2b.sandbox_sync import sandbox_api as sync_sandbox_api @@ -20,6 +22,15 @@ def _response(): ) +def test_client_rejects_a_bound_idempotency_key(): + with pytest.raises(InvalidArgumentException) as error: + E2B(idempotency_key="bound123") + + assert str(error.value) == ( + "idempotency_key is per sandbox create; pass it to Sandbox.create instead of the E2B client." + ) + + def test_sync_create_sends_configured_idempotency_key(monkeypatch): idempotency_keys = [] @@ -40,6 +51,24 @@ def create(**kwargs): assert idempotency_keys == ["abc123"] +def test_sync_client_create_sends_per_call_idempotency_key(monkeypatch): + idempotency_keys = [] + + def get_api_client(config): + return object() + + def create(**kwargs): + idempotency_keys.append(kwargs["idempotency_key"]) + return _response() + + monkeypatch.setattr(sync_sandbox_api, "get_api_client", get_api_client) + monkeypatch.setattr(sync_sandbox_api.post_v2_sandboxes, "sync_detailed", create) + + E2B(api_key="test-api-key").Sandbox.create("base", idempotency_key="abc123") + + assert idempotency_keys == ["abc123"] + + @pytest.mark.asyncio async def test_async_create_sends_configured_idempotency_key(monkeypatch): idempotency_keys = [] @@ -61,6 +90,27 @@ async def create(**kwargs): assert idempotency_keys == ["abc123"] +@pytest.mark.asyncio +async def test_async_client_create_sends_per_call_idempotency_key(monkeypatch): + idempotency_keys = [] + + def get_api_client(config): + return object() + + async def create(**kwargs): + idempotency_keys.append(kwargs["idempotency_key"]) + return _response() + + monkeypatch.setattr(async_sandbox_api, "get_api_client", get_api_client) + monkeypatch.setattr(async_sandbox_api.post_v2_sandboxes, "asyncio_detailed", create) + + await E2B(api_key="test-api-key").AsyncSandbox.create( + "base", idempotency_key="abc123" + ) + + assert idempotency_keys == ["abc123"] + + def test_sync_create_does_not_send_idempotency_key_when_unset(monkeypatch): idempotency_keys = [] From e3373a6217e239a6e183ace766965bc5ae872a40 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:58:34 +0000 Subject: [PATCH 5/6] fix(sdk): scope idempotency keys to sandbox creation Co-Authored-By: mish@e2b.dev --- .changeset/sandbox-create-idempotency.md | 6 +- packages/js-sdk/src/client.ts | 12 +- packages/js-sdk/src/connectionConfig.ts | 11 +- packages/js-sdk/src/sandbox/sandboxApi.ts | 15 +- .../js-sdk/tests/sandbox/idempotency.test.ts | 70 ++++++-- packages/python-sdk/e2b/client.py | 5 +- packages/python-sdk/e2b/connection_config.py | 16 +- packages/python-sdk/e2b/sandbox_async/main.py | 8 + .../e2b/sandbox_async/sandbox_api.py | 7 +- packages/python-sdk/e2b/sandbox_sync/main.py | 8 + .../e2b/sandbox_sync/sandbox_api.py | 7 +- .../tests/test_connection_config.py | 53 ------ .../tests/test_sandbox_create_idempotency.py | 157 ++++++++++++++++++ 13 files changed, 261 insertions(+), 114 deletions(-) diff --git a/.changeset/sandbox-create-idempotency.md b/.changeset/sandbox-create-idempotency.md index 3409958432..6fc8c8d73e 100644 --- a/.changeset/sandbox-create-idempotency.md +++ b/.changeset/sandbox-create-idempotency.md @@ -3,6 +3,6 @@ '@e2b/python-sdk': minor --- -- Added `idempotencyKey` (JavaScript) and `idempotency_key` (Python) connection - options that send `Idempotency-Key` on sandbox creation (1-255 lowercase - letters, digits or hyphens). +- Added an `idempotencyKey` (JavaScript) and `idempotency_key` (Python) option + to `Sandbox.create` that sends `Idempotency-Key` on sandbox creation. Keys + must contain 1–255 lowercase ASCII letters, digits or hyphens. diff --git a/packages/js-sdk/src/client.ts b/packages/js-sdk/src/client.ts index de28224237..66a2816a25 100644 --- a/packages/js-sdk/src/client.ts +++ b/packages/js-sdk/src/client.ts @@ -9,11 +9,10 @@ import { Volume } from './volume' /** * Connection options bound to an {@link E2B} client. * - * Same as {@link ConnectionOpts} without `signal` or `idempotencyKey`. - * `signal` cancels a single request, and `idempotencyKey` is scoped to one - * sandbox creation, so both must be passed per call. + * Same as {@link ConnectionOpts} without `signal`, which cancels a single + * request and therefore can only be passed per call. */ -export type E2BClientOpts = Omit +export type E2BClientOpts = Omit /** * E2B client with an explicitly bound connection configuration. @@ -70,7 +69,10 @@ export class E2B { * through this client's resource classes. */ constructor(opts?: E2BClientOpts) { - if ((opts as ConnectionOpts | undefined)?.idempotencyKey !== undefined) { + if ( + (opts as { idempotencyKey?: unknown } | undefined)?.idempotencyKey !== + undefined + ) { throw new InvalidArgumentError( 'idempotencyKey is per sandbox create; pass it to Sandbox.create instead of the E2B client.' ) diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index 77547f3ba1..0cb5ea3ba1 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -1,7 +1,7 @@ import { Logger } from './logs' import { getEnvVar, version } from './api/metadata' import { runtime } from './utils' -import { resolveIdempotencyKey, resolveRetries } from './retry' +import { resolveRetries } from './retry' import { InvalidArgumentError } from './errors' // Remove once all deployments support sandbox subdomains @@ -56,13 +56,6 @@ export interface ConnectionOpts { * @default E2B_API_KEY // environment variable */ apiKey?: string - /** - * Idempotency key sent as the `Idempotency-Key` header when creating a - * sandbox. Retries reuse the key. Keys must contain 1–255 lowercase ASCII - * letters, digits or hyphens (e.g. a lowercase UUID); other keys throw - * `InvalidArgumentError`. - */ - idempotencyKey?: string /** * Whether to validate the format of the E2B API key on the client side. * @@ -469,7 +462,6 @@ export class ConnectionConfig { readonly requestTimeoutMs: number readonly retries: number - readonly idempotencyKey?: string readonly apiKey?: string /** * @deprecated The API key format is no longer validated on the client side; @@ -484,7 +476,6 @@ export class ConnectionConfig { constructor(opts?: ConnectionOpts) { this.apiKey = opts?.apiKey || ConnectionConfig.apiKey - this.idempotencyKey = resolveIdempotencyKey(opts?.idempotencyKey) this.validateApiKey = opts?.validateApiKey this.debug = opts?.debug ?? ConnectionConfig.debug this.domain = opts?.domain || ConnectionConfig.domain diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index c29048ba20..e72329f188 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -15,6 +15,7 @@ import { TemplateError, } from '../errors' import { Paginator } from '../paginator' +import { resolveIdempotencyKey } from '../retry' import { timeoutToSeconds } from '../utils' import type { Volume } from '../volume' import type { McpServer as BaseMcpServer } from './mcp' @@ -739,6 +740,14 @@ function toSandboxCreateResponse( * Options for creating a new Sandbox. */ export interface SandboxOpts extends ConnectionOpts { + /** + * Idempotency key sent as the `Idempotency-Key` header when creating a + * sandbox. Retries reuse the key. Keys must contain 1–255 lowercase ASCII + * letters, digits or hyphens (e.g. a lowercase UUID); other keys throw + * `InvalidArgumentError`. + */ + idempotencyKey?: string + /** * Sandbox template name or ID. * @@ -1885,6 +1894,8 @@ export class SandboxApi extends ClientFactory { timeoutMs?: number, opts?: SandboxOpts ) { + const idempotencyKey = resolveIdempotencyKey(opts?.idempotencyKey) + // onTimeout accepts a bare action (`'pause'` / `'kill'`) or the object form // `{ action, mode }`. The discriminated union type forbids `mode` (and the // deprecated `keepMemory`) on `action: 'kill'`; re-check at runtime for @@ -1977,8 +1988,8 @@ export class SandboxApi extends ClientFactory { const res = await client.api.POST('/v2/sandboxes', { body, params: - config.idempotencyKey !== undefined - ? { header: { 'Idempotency-Key': config.idempotencyKey } } + idempotencyKey !== undefined + ? { header: { 'Idempotency-Key': idempotencyKey } } : undefined, signal: config.getSignal(apiOpts?.requestTimeoutMs, apiOpts?.signal), }) diff --git a/packages/js-sdk/tests/sandbox/idempotency.test.ts b/packages/js-sdk/tests/sandbox/idempotency.test.ts index 688a27cfe5..aac826d531 100644 --- a/packages/js-sdk/tests/sandbox/idempotency.test.ts +++ b/packages/js-sdk/tests/sandbox/idempotency.test.ts @@ -8,6 +8,7 @@ import { TEST_API_KEY, apiUrl } from '../setup' import { setupMockApi } from '../mockApi' const idempotencyKeys: (string | null)[] = [] +const listIdempotencyKeys: (string | null)[] = [] const server = setupMockApi( http.post(apiUrl('/v2/sandboxes'), ({ request }) => { @@ -17,6 +18,10 @@ const server = setupMockApi( templateID: 'base', envdVersion: '0.2.4', }) + }), + http.get(apiUrl('/v2/sandboxes'), ({ request }) => { + listIdempotencyKeys.push(request.headers.get('Idempotency-Key')) + return HttpResponse.json([]) }) ) @@ -26,10 +31,11 @@ afterAll(() => server.close()) afterEach(() => { idempotencyKeys.length = 0 + listIdempotencyKeys.length = 0 server.resetHandlers() }) -test('Sandbox.create sends the configured idempotency key', async () => { +test('Sandbox.create sends the idempotency key', async () => { await Sandbox.create('base', { apiKey: TEST_API_KEY, idempotencyKey: 'abc123', @@ -44,42 +50,72 @@ test('Sandbox.create does not send an idempotency key when unset', async () => { expect(idempotencyKeys).toEqual([null]) }) -test('ConnectionConfig accepts a 255-character idempotency key', () => { +test('Sandbox.create sends a 255-character idempotency key', async () => { const key = 'a'.repeat(255) - expect(new ConnectionConfig({ idempotencyKey: key }).idempotencyKey).toBe(key) + await Sandbox.create('base', { apiKey: TEST_API_KEY, idempotencyKey: key }) + + expect(idempotencyKeys).toEqual([key]) }) -test('ConnectionConfig accepts a lowercase UUID idempotency key', () => { +test('Sandbox.create sends a lowercase UUID idempotency key', async () => { const key = '123e4567-e89b-12d3-a456-426614174000' - expect(new ConnectionConfig({ idempotencyKey: key }).idempotencyKey).toBe(key) -}) + await Sandbox.create('base', { apiKey: TEST_API_KEY, idempotencyKey: key }) -test('ConnectionConfig rejects idempotency keys outside 1-255 characters', () => { - const emptyKey = () => new ConnectionConfig({ idempotencyKey: '' }) - const longKey = () => - new ConnectionConfig({ idempotencyKey: 'a'.repeat(256) }) + expect(idempotencyKeys).toEqual([key]) +}) - expect(emptyKey).toThrow(InvalidArgumentError) - expect(emptyKey).toThrow( +test('Sandbox.create rejects idempotency keys outside 1-255 characters before a request', async () => { + const emptyKey = Sandbox.create('base', { + apiKey: TEST_API_KEY, + idempotencyKey: '', + }) + await expect(emptyKey).rejects.toThrow(InvalidArgumentError) + await expect(emptyKey).rejects.toThrow( 'Invalid idempotencyKey: expected 1-255 characters, got 0.' ) - expect(longKey).toThrow(InvalidArgumentError) - expect(longKey).toThrow( + + const longKey = Sandbox.create('base', { + apiKey: TEST_API_KEY, + idempotencyKey: 'a'.repeat(256), + }) + await expect(longKey).rejects.toThrow(InvalidArgumentError) + await expect(longKey).rejects.toThrow( 'Invalid idempotencyKey: expected 1-255 characters, got 256.' ) + + expect(idempotencyKeys).toEqual([]) }) test.each(['ABC', 'abc_1', ' abc ', '界'])( - 'ConnectionConfig rejects invalid idempotency key characters: %s', - (key) => { - expect(() => new ConnectionConfig({ idempotencyKey: key })).toThrow( + 'Sandbox.create rejects invalid idempotency key characters before a request: %s', + async (key) => { + await expect( + Sandbox.create('base', { apiKey: TEST_API_KEY, idempotencyKey: key }) + ).rejects.toThrow( 'Invalid idempotencyKey: only lowercase letters, digits and hyphens are allowed.' ) + + expect(idempotencyKeys).toEqual([]) } ) +test('ConnectionConfig ignores an untyped idempotency key', () => { + const config = new ConnectionConfig({ idempotencyKey: 'x' } as any) + + expect(config.headers).not.toHaveProperty('Idempotency-Key') +}) + +test('Sandbox.list ignores an untyped idempotency key', async () => { + await Sandbox.list({ + apiKey: TEST_API_KEY, + idempotencyKey: 'x', + } as any).nextItems() + + expect(listIdempotencyKeys).toEqual([null]) +}) + test('E2B rejects a client-bound idempotency key', () => { const createClientWithKey = () => new E2B({ diff --git a/packages/python-sdk/e2b/client.py b/packages/python-sdk/e2b/client.py index 0c31556314..65a548125e 100644 --- a/packages/python-sdk/e2b/client.py +++ b/packages/python-sdk/e2b/client.py @@ -15,8 +15,7 @@ class E2BClientParams(ApiParams, total=False): """Params bound to an :class:`E2B` client, used as the defaults for every - call made through its resource classes. Same shape as :class:`ApiParams`, - except ``idempotency_key`` must be passed per sandbox creation.""" + call made through its resource classes. Same shape as :class:`ApiParams`.""" def _bind(cls: Type[T], api_params: ApiParams) -> Type[T]: @@ -64,7 +63,7 @@ def __init__(self, **opts: Unpack[E2BClientParams]): through this client's resource classes. ``idempotency_key`` must be passed per sandbox creation instead of bound to the client. """ - if opts.get("idempotency_key") is not None: + if cast(Dict[str, object], opts).get("idempotency_key") is not None: raise InvalidArgumentException( "idempotency_key is per sandbox create; pass it to Sandbox.create instead of the E2B client." ) diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index f80dafda76..c818d12317 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -15,7 +15,7 @@ import httpx from e2b.exceptions import InvalidArgumentException -from e2b.retry import resolve_idempotency_key, resolve_max_retries +from e2b.retry import resolve_max_retries from e2b.api.metadata import package_version from e2b.sandbox_domains import is_supported_sandbox_domain @@ -99,12 +99,6 @@ class ApiParams(TypedDict, total=False): api_key: Optional[str] """E2B API Key to use for authentication, defaults to `E2B_API_KEY` environment variable.""" - idempotency_key: Optional[str] - """Idempotency key sent as the ``Idempotency-Key`` header when creating a - sandbox. Retries reuse the key. Keys must contain 1–255 lowercase ASCII - letters, digits or hyphens (e.g. a lowercase UUID); other keys raise - ``InvalidArgumentException``.""" - validate_api_key: Optional[bool] """Deprecated: the API key format is no longer validated on the client side; this option has no effect.""" @@ -290,13 +284,11 @@ def __init__( *, retries: Optional[int] = None, http_version: Optional[HttpVersion] = None, - idempotency_key: Optional[str] = None, ): self.logger = logger self.domain = domain or ConnectionConfig._domain() self.debug = debug if debug is not None else ConnectionConfig._debug() self.api_key = api_key or ConnectionConfig._api_key() - self.idempotency_key = resolve_idempotency_key(idempotency_key) self.validate_api_key = validate_api_key self.headers = {**(headers or {}), **(api_headers or {})} self._user_agent_is_sdk_built = self._apply_user_agent( @@ -413,7 +405,6 @@ def get_api_params( sandbox_url = opts.get("sandbox_url") http_version = opts.get("http_version") retries = opts.get("retries") - idempotency_key = opts.get("idempotency_key") req_headers = self.headers.copy() if headers is not None: @@ -457,11 +448,6 @@ def get_api_params( ), logger=self.logger, retries=retries if retries is not None else self.retries, - idempotency_key=( - idempotency_key - if idempotency_key is not None - else self.idempotency_key - ), ) ) diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 1d32462650..b15b5ffba6 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -182,6 +182,7 @@ async def create( lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[SandboxAsyncVolumeMount] = None, logger: Optional[logging.Logger] = None, + idempotency_key: Optional[str] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -201,6 +202,10 @@ async def create( :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` or ``"pause"`` (omitted from the request when unset, leaving the API's default, currently ``"kill"``, in effect), or an object ``{"action": "pause"|"kill", "mode": "full"|"filesystem"}`` where ``mode`` set to ``"filesystem"`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); an omitted ``mode`` leaves the snapshot kind to the API (``keep_memory`` is a deprecated alias); ``auto_resume``: leave unset to let the API pick the behavior, set ``False`` to opt out explicitly, or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "mode": "filesystem"}}`` :param volume_mounts: Dictionary mapping mount paths to AsyncVolume instances or volume names :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. + :param idempotency_key: Idempotency key sent as the ``Idempotency-Key`` + header when creating a sandbox. Retries reuse it. Keys must contain + 1–255 lowercase ASCII letters, digits or hyphens (e.g. a lowercase + UUID), otherwise ``InvalidArgumentException`` is raised. :return: A Sandbox instance for the new sandbox @@ -233,6 +238,7 @@ async def create( lifecycle=lifecycle, volume_mounts=transformed_mounts, logger=logger, + idempotency_key=idempotency_key, **opts, ) @@ -1184,6 +1190,7 @@ async def _create( lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[list] = None, logger: Optional[logging.Logger] = None, + idempotency_key: Optional[str] = None, **opts: Unpack[ApiParams], ) -> Self: params = cls._resolve_api_params(**opts) @@ -1209,6 +1216,7 @@ async def _create( lifecycle=lifecycle, volume_mounts=volume_mounts, logger=logger, + idempotency_key=idempotency_key, **params, ) diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index d40bdc852f..ebbbfae6b3 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -42,6 +42,7 @@ SandboxNotFoundException, TemplateException, ) +from e2b.retry import resolve_idempotency_key from e2b.sandbox.main import SandboxBase from e2b.sandbox.sandbox_api import ( build_network_update_body, @@ -221,8 +222,10 @@ async def _create_sandbox( lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[List[SandboxVolumeMountAPI]] = None, logger: Optional[logging.Logger] = None, + idempotency_key: Optional[str] = None, **opts: Unpack[ApiParams], ) -> SandboxCreateResponse: + key = resolve_idempotency_key(idempotency_key) params = cls._resolve_api_params(**opts) config = ConnectionConfig(logger=logger, **params) @@ -253,9 +256,7 @@ async def _create_sandbox( res = await post_v2_sandboxes.asyncio_detailed( body=body, client=api_client, - idempotency_key=( - config.idempotency_key if config.idempotency_key is not None else UNSET - ), + idempotency_key=key if key is not None else UNSET, ) if res.status_code >= 300: diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 4e69efacfa..16e8576005 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -178,6 +178,7 @@ def create( lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[SandboxVolumeMount] = None, logger: Optional[logging.Logger] = None, + idempotency_key: Optional[str] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -197,6 +198,10 @@ def create( :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` or ``"pause"`` (omitted from the request when unset, leaving the API's default, currently ``"kill"``, in effect), or an object ``{"action": "pause"|"kill", "mode": "full"|"filesystem"}`` where ``mode`` set to ``"filesystem"`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); an omitted ``mode`` leaves the snapshot kind to the API (``keep_memory`` is a deprecated alias); ``auto_resume``: leave unset to let the API pick the behavior, set ``False`` to opt out explicitly, or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "mode": "filesystem"}}`` :param volume_mounts: Dictionary mapping mount paths to Volume instances or volume names :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. + :param idempotency_key: Idempotency key sent as the ``Idempotency-Key`` + header when creating a sandbox. Retries reuse it. Keys must contain + 1–255 lowercase ASCII letters, digits or hyphens (e.g. a lowercase + UUID), otherwise ``InvalidArgumentException`` is raised. :return: A Sandbox instance for the new sandbox @@ -229,6 +234,7 @@ def create( lifecycle=lifecycle, volume_mounts=transformed_mounts, logger=logger, + idempotency_key=idempotency_key, **opts, ) @@ -1180,6 +1186,7 @@ def _create( lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[list] = None, logger: Optional[logging.Logger] = None, + idempotency_key: Optional[str] = None, **opts: Unpack[ApiParams], ) -> Self: params = cls._resolve_api_params(**opts) @@ -1205,6 +1212,7 @@ def _create( lifecycle=lifecycle, volume_mounts=volume_mounts, logger=logger, + idempotency_key=idempotency_key, **params, ) diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index 0ce34ccc54..8f1d731292 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -41,6 +41,7 @@ SandboxNotFoundException, TemplateException, ) +from e2b.retry import resolve_idempotency_key from e2b.sandbox.main import SandboxBase from e2b.sandbox.sandbox_api import ( build_network_update_body, @@ -220,8 +221,10 @@ def _create_sandbox( lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[List[SandboxVolumeMountAPI]] = None, logger: Optional[logging.Logger] = None, + idempotency_key: Optional[str] = None, **opts: Unpack[ApiParams], ) -> SandboxCreateResponse: + key = resolve_idempotency_key(idempotency_key) params = cls._resolve_api_params(**opts) config = ConnectionConfig(logger=logger, **params) @@ -252,9 +255,7 @@ def _create_sandbox( res = post_v2_sandboxes.sync_detailed( body=body, client=api_client, - idempotency_key=( - config.idempotency_key if config.idempotency_key is not None else UNSET - ), + idempotency_key=key if key is not None else UNSET, ) if res.status_code >= 300: diff --git a/packages/python-sdk/tests/test_connection_config.py b/packages/python-sdk/tests/test_connection_config.py index d29acad62f..b9ff76c157 100644 --- a/packages/python-sdk/tests/test_connection_config.py +++ b/packages/python-sdk/tests/test_connection_config.py @@ -1,5 +1,3 @@ -from typing import cast - import pytest from e2b import ConnectionConfig @@ -7,57 +5,6 @@ from e2b.exceptions import InvalidArgumentException -def test_idempotency_key_accepts_255_characters(): - key = "a" * 255 - - assert ConnectionConfig(idempotency_key=key).idempotency_key == key - - -def test_idempotency_key_accepts_lowercase_uuid(): - key = "123e4567-e89b-12d3-a456-426614174000" - - assert ConnectionConfig(idempotency_key=key).idempotency_key == key - - -@pytest.mark.parametrize( - ("key", "length"), - [("", 0), ("a" * 256, 256)], - ids=["empty", "too-long"], -) -def test_idempotency_key_rejects_invalid_lengths(key, length): - with pytest.raises( - InvalidArgumentException, - match=f"Invalid idempotency_key: expected 1-255 characters, got {length}.", - ): - ConnectionConfig(idempotency_key=key) - - -@pytest.mark.parametrize("key", ["ABC", "abc_1", " abc ", "界"]) -def test_idempotency_key_rejects_invalid_characters(key): - with pytest.raises( - InvalidArgumentException, - match="Invalid idempotency_key: only lowercase letters, digits and hyphens are allowed.", - ): - ConnectionConfig(idempotency_key=key) - - -def test_idempotency_key_rejects_non_strings(): - with pytest.raises( - InvalidArgumentException, - match="Invalid idempotency_key: expected a string, got int.", - ): - ConnectionConfig(idempotency_key=cast(str, 123)) - - -def test_idempotency_key_is_stored_and_propagates(): - config = ConnectionConfig(idempotency_key="abc123") - - assert config.idempotency_key == "abc123" - assert config.get_api_params()["idempotency_key"] == "abc123" - assert ConnectionConfig(**config.get_api_params()).idempotency_key == "abc123" - assert config.get_api_params(idempotency_key="other")["idempotency_key"] == "other" - - def test_api_url_defaults_correctly(monkeypatch): monkeypatch.setenv("E2B_DOMAIN", "") monkeypatch.delenv("E2B_API_URL", raising=False) diff --git a/packages/python-sdk/tests/test_sandbox_create_idempotency.py b/packages/python-sdk/tests/test_sandbox_create_idempotency.py index 5452c2654a..220631eb6b 100644 --- a/packages/python-sdk/tests/test_sandbox_create_idempotency.py +++ b/packages/python-sdk/tests/test_sandbox_create_idempotency.py @@ -1,4 +1,5 @@ from types import SimpleNamespace +from typing import cast import pytest @@ -6,7 +7,9 @@ from e2b.client import E2B from e2b.exceptions import InvalidArgumentException from e2b.sandbox_async import sandbox_api as async_sandbox_api +from e2b.sandbox_async.main import AsyncSandbox from e2b.sandbox_sync import sandbox_api as sync_sandbox_api +from e2b.sandbox_sync.main import Sandbox def _response(): @@ -22,6 +25,30 @@ def _response(): ) +def _mock_sync_create(monkeypatch, requests): + def get_api_client(config): + return object() + + def create(**kwargs): + requests.append(kwargs["idempotency_key"]) + return _response() + + monkeypatch.setattr(sync_sandbox_api, "get_api_client", get_api_client) + monkeypatch.setattr(sync_sandbox_api.post_v2_sandboxes, "sync_detailed", create) + + +def _mock_async_create(monkeypatch, requests): + def get_api_client(config): + return object() + + async def create(**kwargs): + requests.append(kwargs["idempotency_key"]) + return _response() + + monkeypatch.setattr(async_sandbox_api, "get_api_client", get_api_client) + monkeypatch.setattr(async_sandbox_api.post_v2_sandboxes, "asyncio_detailed", create) + + def test_client_rejects_a_bound_idempotency_key(): with pytest.raises(InvalidArgumentException) as error: E2B(idempotency_key="bound123") @@ -31,6 +58,68 @@ def test_client_rejects_a_bound_idempotency_key(): ) +@pytest.mark.parametrize("key", ["a" * 255, "123e4567-e89b-12d3-a456-426614174000"]) +def test_sync_create_accepts_valid_idempotency_keys(monkeypatch, key): + requests = [] + _mock_sync_create(monkeypatch, requests) + + Sandbox.create("base", api_key="test-api-key", debug=False, idempotency_key=key) + + assert requests == [key] + + +@pytest.mark.parametrize( + ("key", "message"), + [ + ("", "Invalid idempotency_key: expected 1-255 characters, got 0."), + ( + "a" * 256, + "Invalid idempotency_key: expected 1-255 characters, got 256.", + ), + ( + "ABC", + "Invalid idempotency_key: only lowercase letters, digits and hyphens are allowed.", + ), + ( + "abc_1", + "Invalid idempotency_key: only lowercase letters, digits and hyphens are allowed.", + ), + ( + " abc ", + "Invalid idempotency_key: only lowercase letters, digits and hyphens are allowed.", + ), + ( + "界", + "Invalid idempotency_key: only lowercase letters, digits and hyphens are allowed.", + ), + ( + cast(str, 123), + "Invalid idempotency_key: expected a string, got int.", + ), + ], + ids=[ + "empty", + "too-long", + "uppercase", + "underscore", + "whitespace", + "unicode", + "non-string", + ], +) +def test_sync_create_rejects_invalid_idempotency_keys_before_request( + monkeypatch, key, message +): + requests = [] + _mock_sync_create(monkeypatch, requests) + + with pytest.raises(InvalidArgumentException) as error: + Sandbox.create("base", api_key="test-api-key", debug=False, idempotency_key=key) + + assert str(error.value) == message + assert requests == [] + + def test_sync_create_sends_configured_idempotency_key(monkeypatch): idempotency_keys = [] @@ -69,6 +158,74 @@ def create(**kwargs): assert idempotency_keys == ["abc123"] +@pytest.mark.parametrize("key", ["a" * 255, "123e4567-e89b-12d3-a456-426614174000"]) +@pytest.mark.asyncio +async def test_async_create_accepts_valid_idempotency_keys(monkeypatch, key): + requests = [] + _mock_async_create(monkeypatch, requests) + + await AsyncSandbox.create( + "base", api_key="test-api-key", debug=False, idempotency_key=key + ) + + assert requests == [key] + + +@pytest.mark.parametrize( + ("key", "message"), + [ + ("", "Invalid idempotency_key: expected 1-255 characters, got 0."), + ( + "a" * 256, + "Invalid idempotency_key: expected 1-255 characters, got 256.", + ), + ( + "ABC", + "Invalid idempotency_key: only lowercase letters, digits and hyphens are allowed.", + ), + ( + "abc_1", + "Invalid idempotency_key: only lowercase letters, digits and hyphens are allowed.", + ), + ( + " abc ", + "Invalid idempotency_key: only lowercase letters, digits and hyphens are allowed.", + ), + ( + "界", + "Invalid idempotency_key: only lowercase letters, digits and hyphens are allowed.", + ), + ( + cast(str, 123), + "Invalid idempotency_key: expected a string, got int.", + ), + ], + ids=[ + "empty", + "too-long", + "uppercase", + "underscore", + "whitespace", + "unicode", + "non-string", + ], +) +@pytest.mark.asyncio +async def test_async_create_rejects_invalid_idempotency_keys_before_request( + monkeypatch, key, message +): + requests = [] + _mock_async_create(monkeypatch, requests) + + with pytest.raises(InvalidArgumentException) as error: + await AsyncSandbox.create( + "base", api_key="test-api-key", debug=False, idempotency_key=key + ) + + assert str(error.value) == message + assert requests == [] + + @pytest.mark.asyncio async def test_async_create_sends_configured_idempotency_key(monkeypatch): idempotency_keys = [] From 2bd4e77c922298e1f29a1563edd972393a0edd13 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:01:47 +0000 Subject: [PATCH 6/6] fix(sdk): remove client-bound idempotency guards Co-Authored-By: mish@e2b.dev --- packages/js-sdk/src/client.ts | 10 ---------- packages/js-sdk/tests/sandbox/idempotency.test.ts | 13 ------------- packages/python-sdk/e2b/client.py | 12 +----------- .../tests/test_sandbox_create_idempotency.py | 9 --------- 4 files changed, 1 insertion(+), 43 deletions(-) diff --git a/packages/js-sdk/src/client.ts b/packages/js-sdk/src/client.ts index 66a2816a25..3ae82dcec5 100644 --- a/packages/js-sdk/src/client.ts +++ b/packages/js-sdk/src/client.ts @@ -1,5 +1,4 @@ import { ConnectionOpts } from './connectionConfig' -import { InvalidArgumentError } from './errors' import { Sandbox } from './sandbox' import { Secret } from './secret' import { Template, TemplateBase } from './template' @@ -69,15 +68,6 @@ export class E2B { * through this client's resource classes. */ constructor(opts?: E2BClientOpts) { - if ( - (opts as { idempotencyKey?: unknown } | undefined)?.idempotencyKey !== - undefined - ) { - throw new InvalidArgumentError( - 'idempotencyKey is per sandbox create; pass it to Sandbox.create instead of the E2B client.' - ) - } - // Options are copied so later mutations of the caller's object cannot // change the bound configuration. `signal` is dropped rather than only // typed away, since it cancels a single request and a caller passing a diff --git a/packages/js-sdk/tests/sandbox/idempotency.test.ts b/packages/js-sdk/tests/sandbox/idempotency.test.ts index aac826d531..82c82e59d3 100644 --- a/packages/js-sdk/tests/sandbox/idempotency.test.ts +++ b/packages/js-sdk/tests/sandbox/idempotency.test.ts @@ -116,19 +116,6 @@ test('Sandbox.list ignores an untyped idempotency key', async () => { expect(listIdempotencyKeys).toEqual([null]) }) -test('E2B rejects a client-bound idempotency key', () => { - const createClientWithKey = () => - new E2B({ - apiKey: TEST_API_KEY, - idempotencyKey: 'bound123', - } as any) - - expect(createClientWithKey).toThrow(InvalidArgumentError) - expect(createClientWithKey).toThrow( - 'idempotencyKey is per sandbox create; pass it to Sandbox.create instead of the E2B client.' - ) -}) - test('client.Sandbox.create accepts a per-call idempotency key', async () => { const client = new E2B({ apiKey: TEST_API_KEY }) diff --git a/packages/python-sdk/e2b/client.py b/packages/python-sdk/e2b/client.py index 65a548125e..d704e48160 100644 --- a/packages/python-sdk/e2b/client.py +++ b/packages/python-sdk/e2b/client.py @@ -1,7 +1,6 @@ from typing import Dict, Type, TypeVar, Unpack, cast from e2b.connection_config import ApiParams -from e2b.exceptions import InvalidArgumentException from e2b.sandbox_async.main import AsyncSandbox from e2b.sandbox_sync.main import Sandbox from e2b.secret import AsyncSecret, Secret @@ -40,9 +39,6 @@ class E2B: Multiple clients are fully isolated from each other and from the top-level env-configured exports. - ``idempotency_key`` is scoped to one sandbox creation and must be passed to - ``Sandbox.create`` or ``AsyncSandbox.create``, not bound to the client. - Example: ```python from e2b import E2B @@ -60,14 +56,8 @@ def __init__(self, **opts: Unpack[E2BClientParams]): Create a new client with the API params bound to it. :param opts: API params used as the defaults for every call made - through this client's resource classes. ``idempotency_key`` must - be passed per sandbox creation instead of bound to the client. + through this client's resource classes. """ - if cast(Dict[str, object], opts).get("idempotency_key") is not None: - raise InvalidArgumentException( - "idempotency_key is per sandbox create; pass it to Sandbox.create instead of the E2B client." - ) - # Params are copied so later mutations of the caller's dicts cannot # change the bound configuration. api_params = cast(ApiParams, dict(cast(Dict[str, object], opts))) diff --git a/packages/python-sdk/tests/test_sandbox_create_idempotency.py b/packages/python-sdk/tests/test_sandbox_create_idempotency.py index 220631eb6b..5f11646e52 100644 --- a/packages/python-sdk/tests/test_sandbox_create_idempotency.py +++ b/packages/python-sdk/tests/test_sandbox_create_idempotency.py @@ -49,15 +49,6 @@ async def create(**kwargs): monkeypatch.setattr(async_sandbox_api.post_v2_sandboxes, "asyncio_detailed", create) -def test_client_rejects_a_bound_idempotency_key(): - with pytest.raises(InvalidArgumentException) as error: - E2B(idempotency_key="bound123") - - assert str(error.value) == ( - "idempotency_key is per sandbox create; pass it to Sandbox.create instead of the E2B client." - ) - - @pytest.mark.parametrize("key", ["a" * 255, "123e4567-e89b-12d3-a456-426614174000"]) def test_sync_create_accepts_valid_idempotency_keys(monkeypatch, key): requests = []