Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/sandbox-create-idempotency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'e2b': minor
'@e2b/python-sdk': minor
---

- 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.
19 changes: 19 additions & 0 deletions packages/js-sdk/src/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ 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 {
if (key === undefined) return undefined
if (key.length < 1 || key.length > 255) {
throw new InvalidArgumentError(
`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
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}

export function parseRetryAfter(
value: string | null | undefined
): number | undefined {
Expand Down
15 changes: 15 additions & 0 deletions packages/js-sdk/src/sandbox/sandboxApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -1885,6 +1894,8 @@ export class SandboxApi extends ClientFactory {
timeoutMs?: number,
opts?: SandboxOpts
) {
const idempotencyKey = resolveIdempotencyKey(opts?.idempotencyKey)
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

// 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
Expand Down Expand Up @@ -1976,6 +1987,10 @@ export class SandboxApi extends ClientFactory {
const client = new ApiClient(config)
const res = await client.api.POST('/v2/sandboxes', {
body,
params:
idempotencyKey !== undefined
? { header: { 'Idempotency-Key': idempotencyKey } }
: undefined,
signal: config.getSignal(apiOpts?.requestTimeoutMs, apiOpts?.signal),
})

Expand Down
126 changes: 126 additions & 0 deletions packages/js-sdk/tests/sandbox/idempotency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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 listIdempotencyKeys: (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',
})
}),
http.get(apiUrl('/v2/sandboxes'), ({ request }) => {
listIdempotencyKeys.push(request.headers.get('Idempotency-Key'))
return HttpResponse.json([])
})
)

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))

afterAll(() => server.close())

afterEach(() => {
idempotencyKeys.length = 0
listIdempotencyKeys.length = 0
server.resetHandlers()
})

test('Sandbox.create sends the 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('Sandbox.create sends a 255-character idempotency key', async () => {
const key = 'a'.repeat(255)

await Sandbox.create('base', { apiKey: TEST_API_KEY, idempotencyKey: key })

expect(idempotencyKeys).toEqual([key])
})

test('Sandbox.create sends a lowercase UUID idempotency key', async () => {
const key = '123e4567-e89b-12d3-a456-426614174000'

await Sandbox.create('base', { apiKey: TEST_API_KEY, idempotencyKey: key })

expect(idempotencyKeys).toEqual([key])
})

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.'
)

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 ', '界'])(
'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('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(['abc123', null])
})
19 changes: 19 additions & 0 deletions packages/python-sdk/e2b/retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import random
import re
import time
from typing import Awaitable, Callable, Optional

Expand All @@ -25,6 +26,24 @@ 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)}."
)
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
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.


def parse_retry_after(value: Optional[str]) -> Optional[int]:
if not value:
return None
Expand Down
8 changes: 8 additions & 0 deletions packages/python-sdk/e2b/sandbox_async/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand All @@ -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

Expand Down Expand Up @@ -233,6 +238,7 @@ async def create(
lifecycle=lifecycle,
volume_mounts=transformed_mounts,
logger=logger,
idempotency_key=idempotency_key,
**opts,
)

Expand Down Expand Up @@ -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)
Expand All @@ -1209,6 +1216,7 @@ async def _create(
lifecycle=lifecycle,
volume_mounts=volume_mounts,
logger=logger,
idempotency_key=idempotency_key,
**params,
)

Expand Down
4 changes: 4 additions & 0 deletions packages/python-sdk/e2b/sandbox_async/sandbox_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -253,6 +256,7 @@ async def _create_sandbox(
res = await post_v2_sandboxes.asyncio_detailed(
body=body,
client=api_client,
idempotency_key=key if key is not None else UNSET,
)

if res.status_code >= 300:
Expand Down
8 changes: 8 additions & 0 deletions packages/python-sdk/e2b/sandbox_sync/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand All @@ -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

Expand Down Expand Up @@ -229,6 +234,7 @@ def create(
lifecycle=lifecycle,
volume_mounts=transformed_mounts,
logger=logger,
idempotency_key=idempotency_key,
**opts,
)

Expand Down Expand Up @@ -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)
Expand All @@ -1205,6 +1212,7 @@ def _create(
lifecycle=lifecycle,
volume_mounts=volume_mounts,
logger=logger,
idempotency_key=idempotency_key,
**params,
)

Expand Down
4 changes: 4 additions & 0 deletions packages/python-sdk/e2b/sandbox_sync/sandbox_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -252,6 +255,7 @@ def _create_sandbox(
res = post_v2_sandboxes.sync_detailed(
body=body,
client=api_client,
idempotency_key=key if key is not None else UNSET,
)

if res.status_code >= 300:
Expand Down
Loading
Loading