diff --git a/.changeset/sandbox-create-idempotency.md b/.changeset/sandbox-create-idempotency.md new file mode 100644 index 0000000000..6fc8c8d73e --- /dev/null +++ b/.changeset/sandbox-create-idempotency.md @@ -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. diff --git a/packages/js-sdk/src/retry.ts b/packages/js-sdk/src/retry.ts index ead92931ee..d9b88bac8c 100644 --- a/packages/js-sdk/src/retry.ts +++ b/packages/js-sdk/src/retry.ts @@ -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 +} + 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..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 @@ -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), }) 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..82c82e59d3 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/idempotency.test.ts @@ -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]) +}) diff --git a/packages/python-sdk/e2b/retry.py b/packages/python-sdk/e2b/retry.py index d1b5985f5f..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 @@ -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 + + def parse_retry_after(value: Optional[str]) -> Optional[int]: if not value: return None 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 ec30eb6d7a..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,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: 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 27e658e393..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,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: 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..5f11646e52 --- /dev/null +++ b/packages/python-sdk/tests/test_sandbox_create_idempotency.py @@ -0,0 +1,298 @@ +from types import SimpleNamespace +from typing import cast + +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_async.main import AsyncSandbox +from e2b.sandbox_sync import sandbox_api as sync_sandbox_api +from e2b.sandbox_sync.main import Sandbox + + +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 _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) + + +@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 = [] + + 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) + + sync_sandbox_api.SandboxApi._create_sandbox( + "base", None, None, None, None, None, idempotency_key="abc123" + ) + + 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.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 = [] + + 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 async_sandbox_api.SandboxApi._create_sandbox( + "base", None, None, None, None, None, idempotency_key="abc123" + ) + + 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 = [] + + 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) + + sync_sandbox_api.SandboxApi._create_sandbox("base", None, None, None, None, None) + + assert idempotency_keys == [UNSET] + + +@pytest.mark.asyncio +async def test_async_create_does_not_send_idempotency_key_when_unset(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 async_sandbox_api.SandboxApi._create_sandbox( + "base", None, None, None, None, None + ) + + assert idempotency_keys == [UNSET]