From 430ddbaea7bf87e7a4482bf316a12dbaa2f022e4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:23:52 +0000 Subject: [PATCH 1/6] feat(sdk): switch domain from X-E2B-Alt-Used response header Co-Authored-By: mish@e2b.dev --- .changeset/friendly-planes-speak.md | 7 + packages/js-sdk/src/api/index.ts | 32 ++- packages/js-sdk/src/connectionConfig.ts | 12 +- packages/js-sdk/src/resolvedDomains.ts | 42 ++++ packages/js-sdk/tests/resolvedDomains.test.ts | 184 ++++++++++++++++++ packages/python-sdk/e2b/api/__init__.py | 48 ++++- packages/python-sdk/e2b/connection_config.py | 13 +- packages/python-sdk/e2b/resolved_domains.py | 45 +++++ .../python-sdk/tests/test_logging_option.py | 11 +- .../python-sdk/tests/test_resolved_domains.py | 143 ++++++++++++++ 10 files changed, 517 insertions(+), 20 deletions(-) create mode 100644 .changeset/friendly-planes-speak.md create mode 100644 packages/js-sdk/src/resolvedDomains.ts create mode 100644 packages/js-sdk/tests/resolvedDomains.test.ts create mode 100644 packages/python-sdk/e2b/resolved_domains.py create mode 100644 packages/python-sdk/tests/test_resolved_domains.py diff --git a/.changeset/friendly-planes-speak.md b/.changeset/friendly-planes-speak.md new file mode 100644 index 0000000000..84f679c412 --- /dev/null +++ b/.changeset/friendly-planes-speak.md @@ -0,0 +1,7 @@ +--- +'e2b': minor +'@e2b/python-sdk': minor +--- + +- Switch later API requests to the control-plane domain from `X-E2B-Alt-Used`. +- Keep sandbox-domain fallbacks aligned with the resolved control plane. diff --git a/packages/js-sdk/src/api/index.ts b/packages/js-sdk/src/api/index.ts index 23d9a06307..eb468bdd86 100644 --- a/packages/js-sdk/src/api/index.ts +++ b/packages/js-sdk/src/api/index.ts @@ -12,6 +12,11 @@ import { } from '../errors' import { createApiLogger } from '../logs' import { withRetry } from '../retry' +import { + ALT_USED_HEADER, + recordResolvedDomain, + usesDomainApiUrl, +} from '../resolvedDomains' /** * Map an API error code and message to the matching error class — the same @@ -104,13 +109,30 @@ class ApiClient { ) } + const shouldRecordDomain = usesDomainApiUrl( + config.domain, + config.apiUrl, + config.debug + ) + const fetchWithRetry = withRetry( + createApiFetch(config), + config.retries, + config.requestTimeoutMs + ) + this.api = createClient({ baseUrl: config.apiUrl, - fetch: withRetry( - createApiFetch(config), - config.retries, - config.requestTimeoutMs - ), + fetch: async (input) => { + const response = await fetchWithRetry(input) + if (shouldRecordDomain) { + recordResolvedDomain( + config.domain, + config.apiKey, + response.headers.get(ALT_USED_HEADER) + ) + } + return response + }, // In HTTP 1.1, all connections are considered persistent unless declared otherwise // keepalive: true, headers: { diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index b946cc69b4..ff39776f2d 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -3,6 +3,7 @@ import { getEnvVar, version } from './api/metadata' import { runtime } from './utils' import { resolveRetries } from './retry' import { InvalidArgumentError } from './errors' +import { getResolvedDomain, usesDomainApiUrl } from './resolvedDomains' // Remove once all deployments support sandbox subdomains const supportedDomains = ['e2b.app', 'e2b.dev', 'e2b.pro', 'e2b-staging.dev'] @@ -466,7 +467,7 @@ export class ConnectionConfig { this.apiKey = opts?.apiKey || ConnectionConfig.apiKey this.validateApiKey = opts?.validateApiKey this.debug = opts?.debug ?? ConnectionConfig.debug - this.domain = opts?.domain || ConnectionConfig.domain + const domain = opts?.domain || ConnectionConfig.domain this.requestTimeoutMs = opts?.requestTimeoutMs ?? REQUEST_TIMEOUT_MS this.retries = resolveRetries(opts?.retries ?? DEFAULT_RETRIES) this.logger = opts?.logger @@ -475,10 +476,15 @@ export class ConnectionConfig { this.proxy = opts?.proxy this.httpVersion = resolveHttpVersion(opts?.httpVersion) - this.apiUrl = + const apiUrl = opts?.apiUrl || ConnectionConfig.apiUrl || - (this.debug ? 'http://localhost:3000' : `https://api.${this.domain}`) + (this.debug ? 'http://localhost:3000' : `https://api.${domain}`) + const resolvedDomain = usesDomainApiUrl(domain, apiUrl, this.debug) + ? getResolvedDomain(domain, this.apiKey) + : undefined + this.domain = resolvedDomain ?? domain + this.apiUrl = resolvedDomain ? `https://api.${resolvedDomain}` : apiUrl this.sandboxUrl = opts?.sandboxUrl || ConnectionConfig.sandboxUrl } diff --git a/packages/js-sdk/src/resolvedDomains.ts b/packages/js-sdk/src/resolvedDomains.ts new file mode 100644 index 0000000000..71395fb63f --- /dev/null +++ b/packages/js-sdk/src/resolvedDomains.ts @@ -0,0 +1,42 @@ +// X-E2B-Alt-Used identifies a key's control plane; apiUrl eligibility preserves rebuilt custom-proxy configs. +export const ALT_USED_HEADER = 'X-E2B-Alt-Used' + +const resolvedDomains = new Map() +const hostnamePattern = + /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/ + +function cacheKey(domain: string, apiKey?: string) { + return JSON.stringify([domain, apiKey ?? '']) +} + +export function getResolvedDomain( + domain: string, + apiKey?: string +): string | undefined { + return resolvedDomains.get(cacheKey(domain, apiKey)) +} + +export function recordResolvedDomain( + domain: string, + apiKey: string | undefined, + value: string | null | undefined +) { + const resolved = value?.trim().toLowerCase() + if (!resolved || !hostnamePattern.test(resolved) || resolved === domain) { + return + } + + resolvedDomains.set(cacheKey(domain, apiKey), resolved) +} + +export function usesDomainApiUrl( + domain: string, + apiUrl: string, + debug: boolean +): boolean { + return !debug && apiUrl === `https://api.${domain}` +} + +export function clearResolvedDomains() { + resolvedDomains.clear() +} diff --git a/packages/js-sdk/tests/resolvedDomains.test.ts b/packages/js-sdk/tests/resolvedDomains.test.ts new file mode 100644 index 0000000000..c2fe0d4043 --- /dev/null +++ b/packages/js-sdk/tests/resolvedDomains.test.ts @@ -0,0 +1,184 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest' +import { http, HttpResponse } from 'msw' + +import { ApiClient, ConnectionConfig, Sandbox } from '../src' +import { ALT_USED_HEADER, clearResolvedDomains } from '../src/resolvedDomains' +import { setupMockApi } from './mockApi' + +const API_KEY_A = `e2b_${'a'.repeat(40)}` +const API_KEY_B = `e2b_${'b'.repeat(40)}` +const ORIGINAL_DOMAIN = 'e2b.dev' +const RESOLVED_DOMAIN = 'e2b-juliett.dev' + +let responseHeader: string | null = RESOLVED_DOMAIN +const requestedHosts: string[] = [] + +function jsonResponse(body: unknown) { + return HttpResponse.json( + body, + responseHeader === null + ? undefined + : { headers: { [ALT_USED_HEADER]: responseHeader } } + ) +} + +const server = setupMockApi( + http.get(/\/v2\/sandboxes$/, ({ request }) => { + requestedHosts.push(new URL(request.url).host) + return jsonResponse([]) + }), + http.post(/\/v2\/sandboxes$/, ({ request }) => { + requestedHosts.push(new URL(request.url).host) + return jsonResponse({ + sandboxID: 'sandbox-id', + domain: null, + envdVersion: '0.4.0', + }) + }) +) + +beforeAll(async () => { + await server.listen({ onUnhandledRequest: 'error' }) +}) + +afterAll(async () => { + await server.close() +}) + +beforeEach(() => { + clearResolvedDomains() + requestedHosts.length = 0 + responseHeader = RESOLVED_DOMAIN + server.resetHandlers() +}) + +async function sendRequest(config: ConnectionConfig) { + await new ApiClient(config).api.GET('/v2/sandboxes') +} + +describe('resolved domains', () => { + test('records the control-plane domain and uses it for later requests', async () => { + const original = new ConnectionConfig({ + domain: ORIGINAL_DOMAIN, + apiKey: API_KEY_A, + debug: false, + }) + await sendRequest(original) + + const resolved = new ConnectionConfig({ + domain: ORIGINAL_DOMAIN, + apiKey: API_KEY_A, + debug: false, + }) + expect(resolved.domain).toBe(RESOLVED_DOMAIN) + expect(resolved.apiUrl).toBe(`https://api.${RESOLVED_DOMAIN}`) + + await sendRequest(resolved) + expect(requestedHosts).toEqual([ + `api.${ORIGINAL_DOMAIN}`, + `api.${RESOLVED_DOMAIN}`, + ]) + }) + + test('scopes the resolved domain by api key and original domain', async () => { + await sendRequest( + new ConnectionConfig({ + domain: ORIGINAL_DOMAIN, + apiKey: API_KEY_A, + debug: false, + }) + ) + + expect( + new ConnectionConfig({ + domain: ORIGINAL_DOMAIN, + apiKey: API_KEY_B, + debug: false, + }).domain + ).toBe(ORIGINAL_DOMAIN) + expect( + new ConnectionConfig({ + domain: 'other.dev', + apiKey: API_KEY_A, + debug: false, + }).domain + ).toBe('other.dev') + }) + + test('does not record for custom api URLs or debug configs', async () => { + await sendRequest( + new ConnectionConfig({ + domain: ORIGINAL_DOMAIN, + apiKey: API_KEY_A, + apiUrl: 'https://proxy.example', + debug: false, + }) + ) + await sendRequest( + new ConnectionConfig({ + domain: ORIGINAL_DOMAIN, + apiKey: API_KEY_A, + debug: true, + }) + ) + + const unchanged = new ConnectionConfig({ + domain: ORIGINAL_DOMAIN, + apiKey: API_KEY_A, + debug: false, + }) + expect(unchanged.domain).toBe(ORIGINAL_DOMAIN) + expect(unchanged.apiUrl).toBe(`https://api.${ORIGINAL_DOMAIN}`) + expect(requestedHosts).toEqual(['proxy.example', 'localhost:3000']) + }) + + test('switches explicit canonical api URLs and rebuilt configs', async () => { + const original = new ConnectionConfig({ + domain: ORIGINAL_DOMAIN, + apiKey: API_KEY_A, + apiUrl: `https://api.${ORIGINAL_DOMAIN}`, + debug: false, + }) + await sendRequest(original) + + const rebuilt = new ConnectionConfig({ ...original }) + expect(rebuilt.domain).toBe(RESOLVED_DOMAIN) + expect(rebuilt.apiUrl).toBe(`https://api.${RESOLVED_DOMAIN}`) + }) + + test.each([ + 'https://e2b-juliett.dev', + 'e2b-juliett.dev:443', + 'a b', + '', + ORIGINAL_DOMAIN, + ])('ignores invalid or unchanged response domain %j', async (value) => { + clearResolvedDomains() + responseHeader = value + await sendRequest( + new ConnectionConfig({ + domain: ORIGINAL_DOMAIN, + apiKey: API_KEY_A, + debug: false, + }) + ) + + const unchanged = new ConnectionConfig({ + domain: ORIGINAL_DOMAIN, + apiKey: API_KEY_A, + debug: false, + }) + expect(unchanged.domain).toBe(ORIGINAL_DOMAIN) + expect(unchanged.apiUrl).toBe(`https://api.${ORIGINAL_DOMAIN}`) + }) + + test('uses the resolved domain as the sandbox-domain fallback', async () => { + const sandbox = await Sandbox.create({ + domain: ORIGINAL_DOMAIN, + apiKey: API_KEY_A, + debug: false, + }) + + expect(sandbox.sandboxDomain).toBe(RESOLVED_DOMAIN) + }) +}) diff --git a/packages/python-sdk/e2b/api/__init__.py b/packages/python-sdk/e2b/api/__init__.py index 68f7d9f243..7a7c586e79 100644 --- a/packages/python-sdk/e2b/api/__init__.py +++ b/packages/python-sdk/e2b/api/__init__.py @@ -27,6 +27,11 @@ ServiceBusyException, SandboxException, ) +from e2b.resolved_domains import ( + ALT_USED_HEADER, + record_resolved_domain, + uses_domain_api_url, +) def encode_path_param(value: str) -> str: @@ -252,6 +257,11 @@ def __init__( prefix = "" self._logger = config.logger + self._domain = config.domain + self._api_key = config.api_key + self._should_record_domain = uses_domain_api_url( + config.domain, config.api_url, config.debug + ) headers = { **default_headers, @@ -266,9 +276,15 @@ def __init__( kwargs.pop("auth_header_name", None) kwargs.pop("prefix", None) - httpx_args = { - "event_hooks": self._logging_event_hooks(), - } + event_hooks: dict = {} + for hooks in ( + self._logging_event_hooks(), + self._resolved_domain_event_hooks(), + ): + for event, callbacks in hooks.items(): + event_hooks.setdefault(event, []).extend(callbacks) + + httpx_args = {"event_hooks": event_hooks} if transport is not None: # The proxy lives in the transport; passing `proxy` here too # would mount a fresh, never-closed proxy transport per client. @@ -294,8 +310,34 @@ def __init__( def _logging_event_hooks(self) -> dict: return make_logging_event_hooks(self._logger) + def _resolved_domain_event_hooks(self) -> dict: + if not self._should_record_domain: + return {} + + def on_response(response: Response) -> None: + record_resolved_domain( + self._domain, + self._api_key, + response.headers.get(ALT_USED_HEADER), + ) + + return {"response": [on_response]} + # We need to override the logging hooks for the async usage class AsyncApiClient(ApiClient): def _logging_event_hooks(self) -> dict: return make_async_logging_event_hooks(self._logger) + + def _resolved_domain_event_hooks(self) -> dict: + if not self._should_record_domain: + return {} + + async def on_response(response: Response) -> None: + record_resolved_domain( + self._domain, + self._api_key, + response.headers.get(ALT_USED_HEADER), + ) + + return {"response": [on_response]} diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 6ad35b37f5..0a132f4874 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -10,6 +10,7 @@ from e2b.retry import resolve_max_retries from e2b.api.metadata import package_version from e2b.sandbox_domains import is_supported_sandbox_domain +from e2b.resolved_domains import get_resolved_domain, uses_domain_api_url ProxyTypes = Union[str, httpx.URL, httpx.Proxy] """The forms the ``proxy`` option accepts: a URL string, an ``httpx.URL``, or @@ -279,7 +280,7 @@ def __init__( http_version: Optional[HttpVersion] = None, ): self.logger = logger - self.domain = domain or ConnectionConfig._domain() + 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.validate_api_key = validate_api_key @@ -303,8 +304,16 @@ def __init__( self.api_url = ( api_url or ConnectionConfig._api_url() - or ("http://localhost:3000" if self.debug else f"https://api.{self.domain}") + or ("http://localhost:3000" if self.debug else f"https://api.{domain}") ) + resolved_domain = ( + get_resolved_domain(domain, self.api_key) + if uses_domain_api_url(domain, self.api_url, self.debug) + else None + ) + self.domain = resolved_domain or domain + if resolved_domain: + self.api_url = f"https://api.{resolved_domain}" self._sandbox_url: Optional[str] = ( sandbox_url or ConnectionConfig._sandbox_url() diff --git a/packages/python-sdk/e2b/resolved_domains.py b/packages/python-sdk/e2b/resolved_domains.py new file mode 100644 index 0000000000..f289e75df1 --- /dev/null +++ b/packages/python-sdk/e2b/resolved_domains.py @@ -0,0 +1,45 @@ +"""X-E2B-Alt-Used identifies the key's control plane; api_url eligibility preserves rebuilt custom-proxy configs.""" + +import re +from threading import Lock +from typing import Optional + +ALT_USED_HEADER = "X-E2B-Alt-Used" + +_resolved_domains: dict[tuple[str, str], str] = {} +_resolved_domains_lock = Lock() +_hostname_pattern = re.compile( + r"^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$" +) + + +def _cache_key(domain: str, api_key: Optional[str]) -> tuple[str, str]: + return domain, api_key if api_key is not None else "" + + +def get_resolved_domain(domain: str, api_key: Optional[str] = None) -> Optional[str]: + with _resolved_domains_lock: + return _resolved_domains.get(_cache_key(domain, api_key)) + + +def record_resolved_domain( + domain: str, api_key: Optional[str], value: Optional[str] +) -> None: + if value is None: + return + + resolved = value.strip().lower() + if not resolved or not _hostname_pattern.fullmatch(resolved) or resolved == domain: + return + + with _resolved_domains_lock: + _resolved_domains[_cache_key(domain, api_key)] = resolved + + +def uses_domain_api_url(domain: str, api_url: str, debug: bool) -> bool: + return not debug and api_url == f"https://api.{domain}" + + +def clear_resolved_domains() -> None: + with _resolved_domains_lock: + _resolved_domains.clear() diff --git a/packages/python-sdk/tests/test_logging_option.py b/packages/python-sdk/tests/test_logging_option.py index c105920757..301a2d7782 100644 --- a/packages/python-sdk/tests/test_logging_option.py +++ b/packages/python-sdk/tests/test_logging_option.py @@ -72,17 +72,14 @@ def test_api_client_uses_config_logger(): client.get_httpx_client().close() -def test_api_client_without_logger_emits_no_hooks(): - # With no logger supplied, nothing should be logged (matching the JS SDK, - # which only attaches its logging middleware when a logger is given). +def test_api_client_without_logger_registers_only_resolved_domain_hook(): config = ConnectionConfig(api_key="e2b_" + "0" * 40) client = ApiClient(config) try: assert client._logger is None - assert client.get_httpx_client().event_hooks == { - "request": [], - "response": [], - } + event_hooks = client.get_httpx_client().event_hooks + assert event_hooks["request"] == [] + assert len(event_hooks["response"]) == 1 finally: client.get_httpx_client().close() diff --git a/packages/python-sdk/tests/test_resolved_domains.py b/packages/python-sdk/tests/test_resolved_domains.py new file mode 100644 index 0000000000..5477267e71 --- /dev/null +++ b/packages/python-sdk/tests/test_resolved_domains.py @@ -0,0 +1,143 @@ +import httpx +import pytest + +from e2b.api import ApiClient, AsyncApiClient +from e2b.connection_config import ConnectionConfig +from e2b.resolved_domains import ALT_USED_HEADER, clear_resolved_domains + +API_KEY_A = "e2b_" + "a" * 40 +API_KEY_B = "e2b_" + "b" * 40 +ORIGINAL_DOMAIN = "e2b.dev" +RESOLVED_DOMAIN = "e2b-juliett.dev" + + +@pytest.fixture(autouse=True) +def reset_resolved_domains(): + clear_resolved_domains() + yield + clear_resolved_domains() + + +@pytest.fixture(params=(False, True), ids=("sync", "async")) +def async_mode(request): + return request.param + + +async def send_request(config, async_mode, header_value, requested_hosts): + def handler(request): + requested_hosts.append(request.url.host) + headers = {} if header_value is None else {ALT_USED_HEADER: header_value} + return httpx.Response(200, headers=headers, json=[]) + + transport = httpx.MockTransport(handler) + client_type = AsyncApiClient if async_mode else ApiClient + client = client_type(config, transport=transport) + + if async_mode: + httpx_client = client.get_async_httpx_client() + try: + return await httpx_client.get("/resolved") + finally: + await httpx_client.aclose() + + httpx_client = client.get_httpx_client() + try: + return httpx_client.get("/resolved") + finally: + httpx_client.close() + + +@pytest.mark.asyncio +async def test_resolved_domain_is_cached_per_original_domain_and_api_key( + async_mode, +): + requested_hosts = [] + original = ConnectionConfig(domain=ORIGINAL_DOMAIN, api_key=API_KEY_A, debug=False) + await send_request(original, async_mode, RESOLVED_DOMAIN, requested_hosts) + + resolved = ConnectionConfig(domain=ORIGINAL_DOMAIN, api_key=API_KEY_A, debug=False) + assert resolved.domain == RESOLVED_DOMAIN + assert resolved.api_url == f"https://api.{RESOLVED_DOMAIN}" + + await send_request(resolved, async_mode, RESOLVED_DOMAIN, requested_hosts) + assert requested_hosts == [ + f"api.{ORIGINAL_DOMAIN}", + f"api.{RESOLVED_DOMAIN}", + ] + + assert ( + ConnectionConfig(domain=ORIGINAL_DOMAIN, api_key=API_KEY_B, debug=False).domain + == ORIGINAL_DOMAIN + ) + assert ( + ConnectionConfig(domain="other.dev", api_key=API_KEY_A, debug=False).domain + == "other.dev" + ) + + +@pytest.mark.asyncio +async def test_custom_api_url_and_debug_do_not_record(async_mode): + requested_hosts = [] + await send_request( + ConnectionConfig( + domain=ORIGINAL_DOMAIN, + api_key=API_KEY_A, + api_url="https://proxy.example", + debug=False, + ), + async_mode, + RESOLVED_DOMAIN, + requested_hosts, + ) + await send_request( + ConnectionConfig(domain=ORIGINAL_DOMAIN, api_key=API_KEY_A, debug=True), + async_mode, + RESOLVED_DOMAIN, + requested_hosts, + ) + + unchanged = ConnectionConfig(domain=ORIGINAL_DOMAIN, api_key=API_KEY_A, debug=False) + assert unchanged.domain == ORIGINAL_DOMAIN + assert unchanged.api_url == f"https://api.{ORIGINAL_DOMAIN}" + assert requested_hosts == ["proxy.example", "localhost"] + + +@pytest.mark.asyncio +async def test_explicit_domain_api_url_and_rebuilt_config_are_switched(async_mode): + requested_hosts = [] + original = ConnectionConfig( + domain=ORIGINAL_DOMAIN, + api_key=API_KEY_A, + api_url=f"https://api.{ORIGINAL_DOMAIN}", + debug=False, + ) + await send_request(original, async_mode, RESOLVED_DOMAIN, requested_hosts) + + rebuilt = ConnectionConfig(**original.get_api_params()) + assert rebuilt.domain == RESOLVED_DOMAIN + assert rebuilt.api_url == f"https://api.{RESOLVED_DOMAIN}" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "header_value", + ( + "https://e2b-juliett.dev", + "e2b-juliett.dev:443", + "a b", + "", + ORIGINAL_DOMAIN, + ), +) +async def test_invalid_or_unchanged_domains_are_ignored(async_mode, header_value): + requested_hosts = [] + await send_request( + ConnectionConfig(domain=ORIGINAL_DOMAIN, api_key=API_KEY_A, debug=False), + async_mode, + header_value, + requested_hosts, + ) + + unchanged = ConnectionConfig(domain=ORIGINAL_DOMAIN, api_key=API_KEY_A, debug=False) + assert unchanged.domain == ORIGINAL_DOMAIN + assert unchanged.api_url == f"https://api.{ORIGINAL_DOMAIN}" From ea85bb4719403a7db5f5c91215ab847a57cf8d22 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:24:53 +0000 Subject: [PATCH 2/6] refactor(sdk): pass fetch init through, clarify resolved-domain comments Co-Authored-By: mish@e2b.dev --- .changeset/friendly-planes-speak.md | 5 +++-- packages/js-sdk/src/api/index.ts | 24 +++++++++++---------- packages/js-sdk/src/resolvedDomains.ts | 5 ++++- packages/python-sdk/e2b/resolved_domains.py | 7 +++++- 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/.changeset/friendly-planes-speak.md b/.changeset/friendly-planes-speak.md index 84f679c412..fd30a9e3af 100644 --- a/.changeset/friendly-planes-speak.md +++ b/.changeset/friendly-planes-speak.md @@ -3,5 +3,6 @@ '@e2b/python-sdk': minor --- -- Switch later API requests to the control-plane domain from `X-E2B-Alt-Used`. -- Keep sandbox-domain fallbacks aligned with the resolved control plane. +- Discover the control-plane domain from the `X-E2B-Alt-Used` header the global API proxy returns and use it for later API calls in the process (cached per domain + API key). +- The sandbox domain falls back to the discovered domain, so envd traffic reaches the right plane. +- No switching with a custom `apiUrl`/`E2B_API_URL` or in debug mode. diff --git a/packages/js-sdk/src/api/index.ts b/packages/js-sdk/src/api/index.ts index eb468bdd86..1da32c4c77 100644 --- a/packages/js-sdk/src/api/index.ts +++ b/packages/js-sdk/src/api/index.ts @@ -120,19 +120,21 @@ class ApiClient { config.requestTimeoutMs ) + const apiFetch: typeof fetch = async (input, init) => { + const response = await fetchWithRetry(input, init) + if (shouldRecordDomain) { + recordResolvedDomain( + config.domain, + config.apiKey, + response.headers.get(ALT_USED_HEADER) + ) + } + return response + } + this.api = createClient({ baseUrl: config.apiUrl, - fetch: async (input) => { - const response = await fetchWithRetry(input) - if (shouldRecordDomain) { - recordResolvedDomain( - config.domain, - config.apiKey, - response.headers.get(ALT_USED_HEADER) - ) - } - return response - }, + fetch: apiFetch, // In HTTP 1.1, all connections are considered persistent unless declared otherwise // keepalive: true, headers: { diff --git a/packages/js-sdk/src/resolvedDomains.ts b/packages/js-sdk/src/resolvedDomains.ts index 71395fb63f..f994b9e9a1 100644 --- a/packages/js-sdk/src/resolvedDomains.ts +++ b/packages/js-sdk/src/resolvedDomains.ts @@ -1,4 +1,7 @@ -// X-E2B-Alt-Used identifies a key's control plane; apiUrl eligibility preserves rebuilt custom-proxy configs. +// The global API proxy names the domain of the control plane that owns the API +// key in X-E2B-Alt-Used; later configs for that key and domain use it directly. +// Eligibility compares apiUrl rather than checking whether it was set because +// configs are rebuilt from resolved ones, which always carry apiUrl. export const ALT_USED_HEADER = 'X-E2B-Alt-Used' const resolvedDomains = new Map() diff --git a/packages/python-sdk/e2b/resolved_domains.py b/packages/python-sdk/e2b/resolved_domains.py index f289e75df1..0d9fc6db0a 100644 --- a/packages/python-sdk/e2b/resolved_domains.py +++ b/packages/python-sdk/e2b/resolved_domains.py @@ -1,4 +1,9 @@ -"""X-E2B-Alt-Used identifies the key's control plane; api_url eligibility preserves rebuilt custom-proxy configs.""" +""" +The global API proxy names the domain of the control plane that owns the API +key in ``X-E2B-Alt-Used``; later configs for that key and domain use it +directly. Eligibility compares ``api_url`` rather than checking whether it was +set because configs are rebuilt from resolved ones, which always carry it. +""" import re from threading import Lock From 8348a799ebbbbb8b2d98a0ed0e01c0e39f3587a5 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:51:18 +0000 Subject: [PATCH 3/6] feat(sdk): skip domain discovery when connection env vars are set Co-Authored-By: mish@e2b.dev --- .changeset/friendly-planes-speak.md | 2 +- packages/js-sdk/src/connectionConfig.ts | 4 ++ packages/js-sdk/src/resolvedDomains.ts | 22 ++++++-- packages/js-sdk/tests/resolvedDomains.test.ts | 53 ++++++++++++++++++- packages/python-sdk/e2b/connection_config.py | 14 ++++- packages/python-sdk/e2b/resolved_domains.py | 23 +++++--- .../python-sdk/tests/test_resolved_domains.py | 40 +++++++++++++- 7 files changed, 142 insertions(+), 16 deletions(-) diff --git a/.changeset/friendly-planes-speak.md b/.changeset/friendly-planes-speak.md index fd30a9e3af..47c8331c90 100644 --- a/.changeset/friendly-planes-speak.md +++ b/.changeset/friendly-planes-speak.md @@ -5,4 +5,4 @@ - Discover the control-plane domain from the `X-E2B-Alt-Used` header the global API proxy returns and use it for later API calls in the process (cached per domain + API key). - The sandbox domain falls back to the discovered domain, so envd traffic reaches the right plane. -- No switching with a custom `apiUrl`/`E2B_API_URL` or in debug mode. +- No switching with a custom `apiUrl`, in debug mode, or when `E2B_DOMAIN`, `E2B_API_URL`, `E2B_SANDBOX_URL` or `E2B_VOLUME_API_URL` is set. diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index ff39776f2d..2d90fc7bb9 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -59,12 +59,16 @@ export interface ConnectionOpts { * Domain to use for the API. * * @default E2B_DOMAIN // environment variable or `e2b.app` + * When none of `E2B_DOMAIN`, `E2B_API_URL`, `E2B_SANDBOX_URL`, or `E2B_VOLUME_API_URL` are set, `apiUrl` is the default + * `https://api.`, and debug is off, the SDK switches to the control-plane domain reported in `X-E2B-Alt-Used`. */ domain?: string /** * API Url to use for the API. * @internal * @default E2B_API_URL // environment variable or `https://api.${domain}` + * When none of `E2B_DOMAIN`, `E2B_API_URL`, `E2B_SANDBOX_URL`, or `E2B_VOLUME_API_URL` are set, `apiUrl` is the default + * `https://api.`, and debug is off, the SDK switches to the control-plane domain reported in `X-E2B-Alt-Used`. */ apiUrl?: string /** diff --git a/packages/js-sdk/src/resolvedDomains.ts b/packages/js-sdk/src/resolvedDomains.ts index f994b9e9a1..1b30af6917 100644 --- a/packages/js-sdk/src/resolvedDomains.ts +++ b/packages/js-sdk/src/resolvedDomains.ts @@ -1,10 +1,18 @@ -// The global API proxy names the domain of the control plane that owns the API -// key in X-E2B-Alt-Used; later configs for that key and domain use it directly. -// Eligibility compares apiUrl rather than checking whether it was set because -// configs are rebuilt from resolved ones, which always carry apiUrl. +// The global API proxy names the control plane for an API key in +// X-E2B-Alt-Used; later configs use it directly. Eligibility compares apiUrl +// because rebuilt configs carry it, and discovery is disabled when any of +// E2B_DOMAIN, E2B_API_URL, E2B_SANDBOX_URL, or E2B_VOLUME_API_URL is set. +import { getEnvVar } from './api/metadata' + export const ALT_USED_HEADER = 'X-E2B-Alt-Used' const resolvedDomains = new Map() +const PINNED_ENV_VARS = [ + 'E2B_DOMAIN', + 'E2B_API_URL', + 'E2B_SANDBOX_URL', + 'E2B_VOLUME_API_URL', +] const hostnamePattern = /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/ @@ -37,7 +45,11 @@ export function usesDomainApiUrl( apiUrl: string, debug: boolean ): boolean { - return !debug && apiUrl === `https://api.${domain}` + return ( + !debug && + !PINNED_ENV_VARS.some((name) => getEnvVar(name)) && + apiUrl === `https://api.${domain}` + ) } export function clearResolvedDomains() { diff --git a/packages/js-sdk/tests/resolvedDomains.test.ts b/packages/js-sdk/tests/resolvedDomains.test.ts index c2fe0d4043..8b503923fd 100644 --- a/packages/js-sdk/tests/resolvedDomains.test.ts +++ b/packages/js-sdk/tests/resolvedDomains.test.ts @@ -1,4 +1,13 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest' +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from 'vitest' import { http, HttpResponse } from 'msw' import { ApiClient, ConnectionConfig, Sandbox } from '../src' @@ -9,6 +18,12 @@ const API_KEY_A = `e2b_${'a'.repeat(40)}` const API_KEY_B = `e2b_${'b'.repeat(40)}` const ORIGINAL_DOMAIN = 'e2b.dev' const RESOLVED_DOMAIN = 'e2b-juliett.dev' +const PINNED_ENV_VALUES = [ + ['E2B_DOMAIN', 'e2b.dev'], + ['E2B_API_URL', 'https://api.e2b.dev'], + ['E2B_SANDBOX_URL', 'https://sandbox.example.com'], + ['E2B_VOLUME_API_URL', 'https://api.e2b.dev'], +] as const let responseHeader: string | null = RESOLVED_DOMAIN const requestedHosts: string[] = [] @@ -49,9 +64,16 @@ beforeEach(() => { clearResolvedDomains() requestedHosts.length = 0 responseHeader = RESOLVED_DOMAIN + for (const [name] of PINNED_ENV_VALUES) { + vi.stubEnv(name, '') + } server.resetHandlers() }) +afterEach(() => { + vi.unstubAllEnvs() +}) + async function sendRequest(config: ConnectionConfig) { await new ApiClient(config).api.GET('/v2/sandboxes') } @@ -146,6 +168,35 @@ describe('resolved domains', () => { expect(rebuilt.apiUrl).toBe(`https://api.${RESOLVED_DOMAIN}`) }) + test.each(PINNED_ENV_VALUES)( + '%s disables domain discovery', + async (name, value) => { + vi.stubEnv(name, value) + const apiUrl = `https://api.${ORIGINAL_DOMAIN}` + await sendRequest( + new ConnectionConfig({ + domain: ORIGINAL_DOMAIN, + apiKey: API_KEY_A, + apiUrl, + debug: false, + }) + ) + + const unchanged = new ConnectionConfig({ + domain: ORIGINAL_DOMAIN, + apiKey: API_KEY_A, + apiUrl, + debug: false, + }) + expect(unchanged.domain).toBe(ORIGINAL_DOMAIN) + await sendRequest(unchanged) + expect(requestedHosts).toEqual([ + `api.${ORIGINAL_DOMAIN}`, + `api.${ORIGINAL_DOMAIN}`, + ]) + } + ) + test.each([ 'https://e2b-juliett.dev', 'e2b-juliett.dev:443', diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 0a132f4874..92b6d88062 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -102,10 +102,20 @@ class ApiParams(TypedDict, total=False): this option has no effect.""" domain: Optional[str] - """E2B domain to use for authentication, defaults to `E2B_DOMAIN` environment variable.""" + """E2B domain to use for authentication, defaults to `E2B_DOMAIN` environment variable. + + When none of ``E2B_DOMAIN``, ``E2B_API_URL``, ``E2B_SANDBOX_URL``, or + ``E2B_VOLUME_API_URL`` are set, ``api_url`` is the default + ``https://api.``, and debug is off, the SDK switches to the + control-plane domain reported in ``X-E2B-Alt-Used``.""" api_url: Optional[str] - """URL to use for the API, defaults to `https://api.`. For internal use only.""" + """URL to use for the API, defaults to `https://api.`. For internal use only. + + When none of ``E2B_DOMAIN``, ``E2B_API_URL``, ``E2B_SANDBOX_URL``, or + ``E2B_VOLUME_API_URL`` are set, ``api_url`` is the default + ``https://api.``, and debug is off, the SDK switches to the + control-plane domain reported in ``X-E2B-Alt-Used``.""" debug: Optional[bool] """Whether to use debug mode, defaults to `E2B_DEBUG` environment variable.""" diff --git a/packages/python-sdk/e2b/resolved_domains.py b/packages/python-sdk/e2b/resolved_domains.py index 0d9fc6db0a..39cd178047 100644 --- a/packages/python-sdk/e2b/resolved_domains.py +++ b/packages/python-sdk/e2b/resolved_domains.py @@ -1,10 +1,11 @@ -""" -The global API proxy names the domain of the control plane that owns the API -key in ``X-E2B-Alt-Used``; later configs for that key and domain use it -directly. Eligibility compares ``api_url`` rather than checking whether it was -set because configs are rebuilt from resolved ones, which always carry it. +"""The proxy reports an API key's control plane in ``X-E2B-Alt-Used``. + +Eligibility compares ``api_url`` because rebuilt configs carry it; setting +E2B_DOMAIN, E2B_API_URL, E2B_SANDBOX_URL, or E2B_VOLUME_API_URL disables +discovery. """ +import os import re from threading import Lock from typing import Optional @@ -13,6 +14,12 @@ _resolved_domains: dict[tuple[str, str], str] = {} _resolved_domains_lock = Lock() +_PINNED_ENV_VARS = ( + "E2B_DOMAIN", + "E2B_API_URL", + "E2B_SANDBOX_URL", + "E2B_VOLUME_API_URL", +) _hostname_pattern = re.compile( r"^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$" ) @@ -42,7 +49,11 @@ def record_resolved_domain( def uses_domain_api_url(domain: str, api_url: str, debug: bool) -> bool: - return not debug and api_url == f"https://api.{domain}" + return ( + not debug + and not any(os.getenv(name) for name in _PINNED_ENV_VARS) + and api_url == f"https://api.{domain}" + ) def clear_resolved_domains() -> None: diff --git a/packages/python-sdk/tests/test_resolved_domains.py b/packages/python-sdk/tests/test_resolved_domains.py index 5477267e71..9d2f20da7e 100644 --- a/packages/python-sdk/tests/test_resolved_domains.py +++ b/packages/python-sdk/tests/test_resolved_domains.py @@ -9,10 +9,18 @@ API_KEY_B = "e2b_" + "b" * 40 ORIGINAL_DOMAIN = "e2b.dev" RESOLVED_DOMAIN = "e2b-juliett.dev" +PINNED_ENV_VALUES = ( + ("E2B_DOMAIN", "e2b.dev"), + ("E2B_API_URL", "https://api.e2b.dev"), + ("E2B_SANDBOX_URL", "https://sandbox.example.com"), + ("E2B_VOLUME_API_URL", "https://api.e2b.dev"), +) @pytest.fixture(autouse=True) -def reset_resolved_domains(): +def reset_resolved_domains(monkeypatch): + for name, _ in PINNED_ENV_VALUES: + monkeypatch.delenv(name, raising=False) clear_resolved_domains() yield clear_resolved_domains() @@ -118,6 +126,36 @@ async def test_explicit_domain_api_url_and_rebuilt_config_are_switched(async_mod assert rebuilt.api_url == f"https://api.{RESOLVED_DOMAIN}" +@pytest.mark.asyncio +@pytest.mark.parametrize(("name", "value"), PINNED_ENV_VALUES) +async def test_pinned_environment_disables_domain_discovery( + async_mode, monkeypatch, name, value +): + monkeypatch.setenv(name, value) + requested_hosts = [] + api_url = f"https://api.{ORIGINAL_DOMAIN}" + original = ConnectionConfig( + domain=ORIGINAL_DOMAIN, + api_key=API_KEY_A, + api_url=api_url, + debug=False, + ) + await send_request(original, async_mode, RESOLVED_DOMAIN, requested_hosts) + + unchanged = ConnectionConfig( + domain=ORIGINAL_DOMAIN, + api_key=API_KEY_A, + api_url=api_url, + debug=False, + ) + assert unchanged.domain == ORIGINAL_DOMAIN + await send_request(unchanged, async_mode, RESOLVED_DOMAIN, requested_hosts) + assert requested_hosts == [ + f"api.{ORIGINAL_DOMAIN}", + f"api.{ORIGINAL_DOMAIN}", + ] + + @pytest.mark.asyncio @pytest.mark.parametrize( "header_value", From 8f70a1f0c0c94364139aedfbe8ac7641455ba7a3 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:51:53 +0000 Subject: [PATCH 4/6] docs(sdk): keep domain discovery note out of @default Co-Authored-By: mish@e2b.dev --- packages/js-sdk/src/connectionConfig.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index 2d90fc7bb9..f1e0b7178e 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -58,17 +58,18 @@ export interface ConnectionOpts { /** * Domain to use for the API. * - * @default E2B_DOMAIN // environment variable or `e2b.app` * When none of `E2B_DOMAIN`, `E2B_API_URL`, `E2B_SANDBOX_URL`, or `E2B_VOLUME_API_URL` are set, `apiUrl` is the default * `https://api.`, and debug is off, the SDK switches to the control-plane domain reported in `X-E2B-Alt-Used`. + * + * @default E2B_DOMAIN // environment variable or `e2b.app` */ domain?: string /** * API Url to use for the API. - * @internal - * @default E2B_API_URL // environment variable or `https://api.${domain}` * When none of `E2B_DOMAIN`, `E2B_API_URL`, `E2B_SANDBOX_URL`, or `E2B_VOLUME_API_URL` are set, `apiUrl` is the default * `https://api.`, and debug is off, the SDK switches to the control-plane domain reported in `X-E2B-Alt-Used`. + * @internal + * @default E2B_API_URL // environment variable or `https://api.${domain}` */ apiUrl?: string /** From c893d9c3cd03b5bf08254e7b9cc382e2f9c4e5df Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:08:59 +0000 Subject: [PATCH 5/6] refactor(sdk): gate domain discovery on unspecified domain/apiUrl/sandboxUrl Co-Authored-By: mish@e2b.dev --- .changeset/friendly-planes-speak.md | 4 +- packages/js-sdk/src/api/index.ts | 14 +- packages/js-sdk/src/connectionConfig.ts | 23 +- packages/js-sdk/src/resolvedDomains.ts | 42 +-- packages/js-sdk/tests/resolvedDomains.test.ts | 257 ++++++++---------- packages/python-sdk/e2b/api/__init__.py | 8 +- packages/python-sdk/e2b/connection_config.py | 40 ++- packages/python-sdk/e2b/resolved_domains.py | 40 +-- .../python-sdk/tests/test_logging_option.py | 7 +- .../python-sdk/tests/test_resolved_domains.py | 225 ++++++++------- 10 files changed, 305 insertions(+), 355 deletions(-) diff --git a/.changeset/friendly-planes-speak.md b/.changeset/friendly-planes-speak.md index 47c8331c90..5d268283b8 100644 --- a/.changeset/friendly-planes-speak.md +++ b/.changeset/friendly-planes-speak.md @@ -3,6 +3,6 @@ '@e2b/python-sdk': minor --- -- Discover the control-plane domain from the `X-E2B-Alt-Used` header the global API proxy returns and use it for later API calls in the process (cached per domain + API key). +- Discover the control-plane domain from the `X-E2B-Alt-Used` header the global API proxy returns and use it for later API calls in the process (cached per API key). - The sandbox domain falls back to the discovered domain, so envd traffic reaches the right plane. -- No switching with a custom `apiUrl`, in debug mode, or when `E2B_DOMAIN`, `E2B_API_URL`, `E2B_SANDBOX_URL` or `E2B_VOLUME_API_URL` is set. +- Only applies when `domain`, `apiUrl` and `sandboxUrl` aren't set (as options or `E2B_DOMAIN`/`E2B_API_URL`/`E2B_SANDBOX_URL`) and debug is off. diff --git a/packages/js-sdk/src/api/index.ts b/packages/js-sdk/src/api/index.ts index 1da32c4c77..fd6cbd5080 100644 --- a/packages/js-sdk/src/api/index.ts +++ b/packages/js-sdk/src/api/index.ts @@ -12,11 +12,7 @@ import { } from '../errors' import { createApiLogger } from '../logs' import { withRetry } from '../retry' -import { - ALT_USED_HEADER, - recordResolvedDomain, - usesDomainApiUrl, -} from '../resolvedDomains' +import { ALT_USED_HEADER, recordResolvedDomain } from '../resolvedDomains' /** * Map an API error code and message to the matching error class — the same @@ -109,11 +105,6 @@ class ApiClient { ) } - const shouldRecordDomain = usesDomainApiUrl( - config.domain, - config.apiUrl, - config.debug - ) const fetchWithRetry = withRetry( createApiFetch(config), config.retries, @@ -122,9 +113,8 @@ class ApiClient { const apiFetch: typeof fetch = async (input, init) => { const response = await fetchWithRetry(input, init) - if (shouldRecordDomain) { + if (config.discoverDomain) { recordResolvedDomain( - config.domain, config.apiKey, response.headers.get(ALT_USED_HEADER) ) diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index f1e0b7178e..79c7ebfd4e 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -3,7 +3,7 @@ import { getEnvVar, version } from './api/metadata' import { runtime } from './utils' import { resolveRetries } from './retry' import { InvalidArgumentError } from './errors' -import { getResolvedDomain, usesDomainApiUrl } from './resolvedDomains' +import { getResolvedDomain } from './resolvedDomains' // Remove once all deployments support sandbox subdomains const supportedDomains = ['e2b.app', 'e2b.dev', 'e2b.pro', 'e2b-staging.dev'] @@ -58,16 +58,13 @@ export interface ConnectionOpts { /** * Domain to use for the API. * - * When none of `E2B_DOMAIN`, `E2B_API_URL`, `E2B_SANDBOX_URL`, or `E2B_VOLUME_API_URL` are set, `apiUrl` is the default - * `https://api.`, and debug is off, the SDK switches to the control-plane domain reported in `X-E2B-Alt-Used`. + * When `domain`, `apiUrl` and `sandboxUrl` aren't set (as options or via `E2B_DOMAIN`/`E2B_API_URL`/`E2B_SANDBOX_URL`) and debug is off, the SDK switches to the control-plane domain the API reports in `X-E2B-Alt-Used`. * * @default E2B_DOMAIN // environment variable or `e2b.app` */ domain?: string /** * API Url to use for the API. - * When none of `E2B_DOMAIN`, `E2B_API_URL`, `E2B_SANDBOX_URL`, or `E2B_VOLUME_API_URL` are set, `apiUrl` is the default - * `https://api.`, and debug is off, the SDK switches to the control-plane domain reported in `X-E2B-Alt-Used`. * @internal * @default E2B_API_URL // environment variable or `https://api.${domain}` */ @@ -448,6 +445,8 @@ export class ConnectionConfig { } readonly debug: boolean + /** @internal */ + readonly discoverDomain: boolean readonly domain: string readonly apiUrl: string readonly sandboxUrl?: string @@ -472,6 +471,16 @@ export class ConnectionConfig { this.apiKey = opts?.apiKey || ConnectionConfig.apiKey this.validateApiKey = opts?.validateApiKey this.debug = opts?.debug ?? ConnectionConfig.debug + this.discoverDomain = + !this.debug && + !( + opts?.domain || + getEnvVar('E2B_DOMAIN') || + opts?.apiUrl || + ConnectionConfig.apiUrl || + opts?.sandboxUrl || + ConnectionConfig.sandboxUrl + ) const domain = opts?.domain || ConnectionConfig.domain this.requestTimeoutMs = opts?.requestTimeoutMs ?? REQUEST_TIMEOUT_MS this.retries = resolveRetries(opts?.retries ?? DEFAULT_RETRIES) @@ -485,8 +494,8 @@ export class ConnectionConfig { opts?.apiUrl || ConnectionConfig.apiUrl || (this.debug ? 'http://localhost:3000' : `https://api.${domain}`) - const resolvedDomain = usesDomainApiUrl(domain, apiUrl, this.debug) - ? getResolvedDomain(domain, this.apiKey) + const resolvedDomain = this.discoverDomain + ? getResolvedDomain(this.apiKey) : undefined this.domain = resolvedDomain ?? domain this.apiUrl = resolvedDomain ? `https://api.${resolvedDomain}` : apiUrl diff --git a/packages/js-sdk/src/resolvedDomains.ts b/packages/js-sdk/src/resolvedDomains.ts index 1b30af6917..270f3799f8 100644 --- a/packages/js-sdk/src/resolvedDomains.ts +++ b/packages/js-sdk/src/resolvedDomains.ts @@ -1,55 +1,31 @@ -// The global API proxy names the control plane for an API key in -// X-E2B-Alt-Used; later configs use it directly. Eligibility compares apiUrl -// because rebuilt configs carry it, and discovery is disabled when any of -// E2B_DOMAIN, E2B_API_URL, E2B_SANDBOX_URL, or E2B_VOLUME_API_URL is set. -import { getEnvVar } from './api/metadata' +// The API proxy reports the control-plane host in X-E2B-Alt-Used; discovery is +// cached per API key and only runs when domain, apiUrl, and sandboxUrl are unset +// and debug is off. export const ALT_USED_HEADER = 'X-E2B-Alt-Used' const resolvedDomains = new Map() -const PINNED_ENV_VARS = [ - 'E2B_DOMAIN', - 'E2B_API_URL', - 'E2B_SANDBOX_URL', - 'E2B_VOLUME_API_URL', -] const hostnamePattern = /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/ -function cacheKey(domain: string, apiKey?: string) { - return JSON.stringify([domain, apiKey ?? '']) +function cacheKey(apiKey?: string) { + return apiKey ?? '' } -export function getResolvedDomain( - domain: string, - apiKey?: string -): string | undefined { - return resolvedDomains.get(cacheKey(domain, apiKey)) +export function getResolvedDomain(apiKey?: string): string | undefined { + return resolvedDomains.get(cacheKey(apiKey)) } export function recordResolvedDomain( - domain: string, apiKey: string | undefined, value: string | null | undefined ) { const resolved = value?.trim().toLowerCase() - if (!resolved || !hostnamePattern.test(resolved) || resolved === domain) { + if (!resolved || !hostnamePattern.test(resolved)) { return } - resolvedDomains.set(cacheKey(domain, apiKey), resolved) -} - -export function usesDomainApiUrl( - domain: string, - apiUrl: string, - debug: boolean -): boolean { - return ( - !debug && - !PINNED_ENV_VARS.some((name) => getEnvVar(name)) && - apiUrl === `https://api.${domain}` - ) + resolvedDomains.set(cacheKey(apiKey), resolved) } export function clearResolvedDomains() { diff --git a/packages/js-sdk/tests/resolvedDomains.test.ts b/packages/js-sdk/tests/resolvedDomains.test.ts index 8b503923fd..2c9c365324 100644 --- a/packages/js-sdk/tests/resolvedDomains.test.ts +++ b/packages/js-sdk/tests/resolvedDomains.test.ts @@ -11,30 +11,51 @@ import { import { http, HttpResponse } from 'msw' import { ApiClient, ConnectionConfig, Sandbox } from '../src' -import { ALT_USED_HEADER, clearResolvedDomains } from '../src/resolvedDomains' +import type { ConnectionOpts } from '../src/connectionConfig' +import { + ALT_USED_HEADER, + clearResolvedDomains, + getResolvedDomain, + recordResolvedDomain, +} from '../src/resolvedDomains' import { setupMockApi } from './mockApi' const API_KEY_A = `e2b_${'a'.repeat(40)}` const API_KEY_B = `e2b_${'b'.repeat(40)}` -const ORIGINAL_DOMAIN = 'e2b.dev' +const DEFAULT_DOMAIN = 'e2b.app' const RESOLVED_DOMAIN = 'e2b-juliett.dev' -const PINNED_ENV_VALUES = [ - ['E2B_DOMAIN', 'e2b.dev'], - ['E2B_API_URL', 'https://api.e2b.dev'], - ['E2B_SANDBOX_URL', 'https://sandbox.example.com'], - ['E2B_VOLUME_API_URL', 'https://api.e2b.dev'], -] as const +const INELIGIBLE_CONFIGS: { + name: string + options?: ConnectionOpts + env?: readonly [string, string] +}[] = [ + { name: 'opts.domain', options: { domain: DEFAULT_DOMAIN } }, + { name: 'opts.apiUrl', options: { apiUrl: 'https://api.custom.example' } }, + { + name: 'opts.sandboxUrl', + options: { sandboxUrl: 'https://sandbox.example' }, + }, + { name: 'E2B_DOMAIN', env: ['E2B_DOMAIN', 'e2b.custom'] }, + { name: 'E2B_API_URL', env: ['E2B_API_URL', 'https://api.custom.example'] }, + { + name: 'E2B_SANDBOX_URL', + env: ['E2B_SANDBOX_URL', 'https://sandbox.example'], + }, + { name: 'debug option', options: { debug: true } }, + { name: 'E2B_DEBUG', env: ['E2B_DEBUG', 'true'] }, +] let responseHeader: string | null = RESOLVED_DOMAIN const requestedHosts: string[] = [] -function jsonResponse(body: unknown) { - return HttpResponse.json( - body, - responseHeader === null - ? undefined - : { headers: { [ALT_USED_HEADER]: responseHeader } } - ) +function jsonResponse(body: unknown, status = 200) { + return HttpResponse.json(body, { + status, + headers: + responseHeader === null + ? undefined + : { [ALT_USED_HEADER]: responseHeader }, + }) } const server = setupMockApi( @@ -44,11 +65,15 @@ const server = setupMockApi( }), http.post(/\/v2\/sandboxes$/, ({ request }) => { requestedHosts.push(new URL(request.url).host) - return jsonResponse({ - sandboxID: 'sandbox-id', - domain: null, - envdVersion: '0.4.0', - }) + return jsonResponse( + { + sandboxID: 'sandbox-id', + clientID: 'client-id', + domain: null, + envdVersion: '0.4.0', + }, + 201 + ) }) ) @@ -64,7 +89,12 @@ beforeEach(() => { clearResolvedDomains() requestedHosts.length = 0 responseHeader = RESOLVED_DOMAIN - for (const [name] of PINNED_ENV_VALUES) { + for (const name of [ + 'E2B_DOMAIN', + 'E2B_API_URL', + 'E2B_SANDBOX_URL', + 'E2B_DEBUG', + ]) { vi.stubEnv(name, '') } server.resetHandlers() @@ -79,157 +109,90 @@ async function sendRequest(config: ConnectionConfig) { } describe('resolved domains', () => { - test('records the control-plane domain and uses it for later requests', async () => { - const original = new ConnectionConfig({ - domain: ORIGINAL_DOMAIN, - apiKey: API_KEY_A, - debug: false, - }) - await sendRequest(original) - - const resolved = new ConnectionConfig({ - domain: ORIGINAL_DOMAIN, - apiKey: API_KEY_A, - debug: false, - }) + test('records a resolved domain for a default config and uses it later', async () => { + await sendRequest(new ConnectionConfig({ apiKey: API_KEY_A })) + + const resolved = new ConnectionConfig({ apiKey: API_KEY_A }) expect(resolved.domain).toBe(RESOLVED_DOMAIN) expect(resolved.apiUrl).toBe(`https://api.${RESOLVED_DOMAIN}`) await sendRequest(resolved) expect(requestedHosts).toEqual([ - `api.${ORIGINAL_DOMAIN}`, + `api.${DEFAULT_DOMAIN}`, `api.${RESOLVED_DOMAIN}`, ]) }) - test('scopes the resolved domain by api key and original domain', async () => { - await sendRequest( - new ConnectionConfig({ - domain: ORIGINAL_DOMAIN, - apiKey: API_KEY_A, - debug: false, - }) - ) + test('does not reuse a resolved domain for a different API key', async () => { + await sendRequest(new ConnectionConfig({ apiKey: API_KEY_A })) - expect( - new ConnectionConfig({ - domain: ORIGINAL_DOMAIN, - apiKey: API_KEY_B, - debug: false, - }).domain - ).toBe(ORIGINAL_DOMAIN) - expect( - new ConnectionConfig({ - domain: 'other.dev', - apiKey: API_KEY_A, - debug: false, - }).domain - ).toBe('other.dev') + const otherKey = new ConnectionConfig({ apiKey: API_KEY_B }) + expect(otherKey.domain).toBe(DEFAULT_DOMAIN) + expect(otherKey.apiUrl).toBe(`https://api.${DEFAULT_DOMAIN}`) }) - test('does not record for custom api URLs or debug configs', async () => { - await sendRequest( - new ConnectionConfig({ - domain: ORIGINAL_DOMAIN, - apiKey: API_KEY_A, - apiUrl: 'https://proxy.example', - debug: false, - }) - ) - await sendRequest( - new ConnectionConfig({ - domain: ORIGINAL_DOMAIN, + test.each(INELIGIBLE_CONFIGS)( + '$name prevents recording and cache switching', + async ({ options, env }) => { + if (env) { + vi.stubEnv(env[0], env[1]) + } + + const ineligible = new ConnectionConfig({ apiKey: API_KEY_A, - debug: true, + ...(options ?? {}), }) - ) - - const unchanged = new ConnectionConfig({ - domain: ORIGINAL_DOMAIN, - apiKey: API_KEY_A, - debug: false, - }) - expect(unchanged.domain).toBe(ORIGINAL_DOMAIN) - expect(unchanged.apiUrl).toBe(`https://api.${ORIGINAL_DOMAIN}`) - expect(requestedHosts).toEqual(['proxy.example', 'localhost:3000']) - }) - - test('switches explicit canonical api URLs and rebuilt configs', async () => { - const original = new ConnectionConfig({ - domain: ORIGINAL_DOMAIN, - apiKey: API_KEY_A, - apiUrl: `https://api.${ORIGINAL_DOMAIN}`, - debug: false, - }) - await sendRequest(original) - - const rebuilt = new ConnectionConfig({ ...original }) - expect(rebuilt.domain).toBe(RESOLVED_DOMAIN) - expect(rebuilt.apiUrl).toBe(`https://api.${RESOLVED_DOMAIN}`) - }) - - test.each(PINNED_ENV_VALUES)( - '%s disables domain discovery', - async (name, value) => { - vi.stubEnv(name, value) - const apiUrl = `https://api.${ORIGINAL_DOMAIN}` - await sendRequest( - new ConnectionConfig({ - domain: ORIGINAL_DOMAIN, - apiKey: API_KEY_A, - apiUrl, - debug: false, - }) + expect(ineligible.discoverDomain).toBe(false) + await sendRequest(ineligible) + + if (env) { + vi.stubEnv(env[0], '') + } + expect(new ConnectionConfig({ apiKey: API_KEY_A }).domain).toBe( + DEFAULT_DOMAIN ) - const unchanged = new ConnectionConfig({ - domain: ORIGINAL_DOMAIN, + clearResolvedDomains() + recordResolvedDomain(API_KEY_A, RESOLVED_DOMAIN) + if (env) { + vi.stubEnv(env[0], env[1]) + } + const cachedConfig = new ConnectionConfig({ apiKey: API_KEY_A, - apiUrl, - debug: false, + ...(options ?? {}), }) - expect(unchanged.domain).toBe(ORIGINAL_DOMAIN) - await sendRequest(unchanged) - expect(requestedHosts).toEqual([ - `api.${ORIGINAL_DOMAIN}`, - `api.${ORIGINAL_DOMAIN}`, - ]) + const expectedDomain = + options?.domain ?? (env?.[0] === 'E2B_DOMAIN' ? env[1] : DEFAULT_DOMAIN) + expect(cachedConfig.domain).toBe(expectedDomain) } ) - test.each([ - 'https://e2b-juliett.dev', - 'e2b-juliett.dev:443', - 'a b', - '', - ORIGINAL_DOMAIN, - ])('ignores invalid or unchanged response domain %j', async (value) => { - clearResolvedDomains() - responseHeader = value - await sendRequest( - new ConnectionConfig({ - domain: ORIGINAL_DOMAIN, - apiKey: API_KEY_A, - debug: false, - }) - ) + test.each(['https://e2b-juliett.dev', 'e2b-juliett.dev:443', 'a b', ''])( + 'ignores malformed response domain %j', + async (value) => { + responseHeader = value + await sendRequest(new ConnectionConfig({ apiKey: API_KEY_A })) + + const unchanged = new ConnectionConfig({ apiKey: API_KEY_A }) + expect(unchanged.domain).toBe(DEFAULT_DOMAIN) + expect(unchanged.apiUrl).toBe(`https://api.${DEFAULT_DOMAIN}`) + } + ) - const unchanged = new ConnectionConfig({ - domain: ORIGINAL_DOMAIN, - apiKey: API_KEY_A, - debug: false, - }) - expect(unchanged.domain).toBe(ORIGINAL_DOMAIN) - expect(unchanged.apiUrl).toBe(`https://api.${ORIGINAL_DOMAIN}`) + test('accepts a valid host matching the default domain', async () => { + responseHeader = DEFAULT_DOMAIN + await sendRequest(new ConnectionConfig({ apiKey: API_KEY_A })) + expect(getResolvedDomain(API_KEY_A)).toBe(DEFAULT_DOMAIN) }) - test('uses the resolved domain as the sandbox-domain fallback', async () => { - const sandbox = await Sandbox.create({ - domain: ORIGINAL_DOMAIN, - apiKey: API_KEY_A, - debug: false, - }) + test('uses the cached domain for a later Sandbox.create', async () => { + await Sandbox.create({ apiKey: API_KEY_A }) + const sandbox = await Sandbox.create({ apiKey: API_KEY_A }) expect(sandbox.sandboxDomain).toBe(RESOLVED_DOMAIN) + expect(requestedHosts).toEqual([ + `api.${DEFAULT_DOMAIN}`, + `api.${RESOLVED_DOMAIN}`, + ]) }) }) diff --git a/packages/python-sdk/e2b/api/__init__.py b/packages/python-sdk/e2b/api/__init__.py index 7a7c586e79..8c346c4100 100644 --- a/packages/python-sdk/e2b/api/__init__.py +++ b/packages/python-sdk/e2b/api/__init__.py @@ -30,7 +30,6 @@ from e2b.resolved_domains import ( ALT_USED_HEADER, record_resolved_domain, - uses_domain_api_url, ) @@ -257,11 +256,8 @@ def __init__( prefix = "" self._logger = config.logger - self._domain = config.domain self._api_key = config.api_key - self._should_record_domain = uses_domain_api_url( - config.domain, config.api_url, config.debug - ) + self._should_record_domain = config._discover_domain headers = { **default_headers, @@ -316,7 +312,6 @@ def _resolved_domain_event_hooks(self) -> dict: def on_response(response: Response) -> None: record_resolved_domain( - self._domain, self._api_key, response.headers.get(ALT_USED_HEADER), ) @@ -335,7 +330,6 @@ def _resolved_domain_event_hooks(self) -> dict: async def on_response(response: Response) -> None: record_resolved_domain( - self._domain, self._api_key, response.headers.get(ALT_USED_HEADER), ) diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 92b6d88062..5b0a681da8 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -10,7 +10,7 @@ from e2b.retry import resolve_max_retries from e2b.api.metadata import package_version from e2b.sandbox_domains import is_supported_sandbox_domain -from e2b.resolved_domains import get_resolved_domain, uses_domain_api_url +from e2b.resolved_domains import get_resolved_domain ProxyTypes = Union[str, httpx.URL, httpx.Proxy] """The forms the ``proxy`` option accepts: a URL string, an ``httpx.URL``, or @@ -104,18 +104,14 @@ class ApiParams(TypedDict, total=False): domain: Optional[str] """E2B domain to use for authentication, defaults to `E2B_DOMAIN` environment variable. - When none of ``E2B_DOMAIN``, ``E2B_API_URL``, ``E2B_SANDBOX_URL``, or - ``E2B_VOLUME_API_URL`` are set, ``api_url`` is the default - ``https://api.``, and debug is off, the SDK switches to the - control-plane domain reported in ``X-E2B-Alt-Used``.""" + When ``domain``, ``api_url`` and ``sandbox_url`` aren't set (as options or + via ``E2B_DOMAIN``/``E2B_API_URL``/``E2B_SANDBOX_URL``) and debug is off, + the SDK switches to the control-plane domain the API reports in + ``X-E2B-Alt-Used``.""" api_url: Optional[str] """URL to use for the API, defaults to `https://api.`. For internal use only. - - When none of ``E2B_DOMAIN``, ``E2B_API_URL``, ``E2B_SANDBOX_URL``, or - ``E2B_VOLUME_API_URL`` are set, ``api_url`` is the default - ``https://api.``, and debug is off, the SDK switches to the - control-plane domain reported in ``X-E2B-Alt-Used``.""" + """ debug: Optional[bool] """Whether to use debug mode, defaults to `E2B_DEBUG` environment variable.""" @@ -290,8 +286,16 @@ def __init__( http_version: Optional[HttpVersion] = None, ): self.logger = logger - domain = domain or ConnectionConfig._domain() self.debug = debug if debug is not None else ConnectionConfig._debug() + configured_api_url = api_url or ConnectionConfig._api_url() + configured_sandbox_url = sandbox_url or ConnectionConfig._sandbox_url() + self._discover_domain = not self.debug and not ( + domain + or os.getenv("E2B_DOMAIN") + or configured_api_url + or configured_sandbox_url + ) + domain = domain or ConnectionConfig._domain() self.api_key = api_key or ConnectionConfig._api_key() self.validate_api_key = validate_api_key self.headers = {**(headers or {}), **(api_headers or {})} @@ -311,23 +315,17 @@ def __init__( request_timeout, ) - self.api_url = ( - api_url - or ConnectionConfig._api_url() - or ("http://localhost:3000" if self.debug else f"https://api.{domain}") + self.api_url = configured_api_url or ( + "http://localhost:3000" if self.debug else f"https://api.{domain}" ) resolved_domain = ( - get_resolved_domain(domain, self.api_key) - if uses_domain_api_url(domain, self.api_url, self.debug) - else None + get_resolved_domain(self.api_key) if self._discover_domain else None ) self.domain = resolved_domain or domain if resolved_domain: self.api_url = f"https://api.{resolved_domain}" - self._sandbox_url: Optional[str] = ( - sandbox_url or ConnectionConfig._sandbox_url() - ) + self._sandbox_url: Optional[str] = configured_sandbox_url self.http_version: HttpVersion = resolve_http_version(http_version) @staticmethod diff --git a/packages/python-sdk/e2b/resolved_domains.py b/packages/python-sdk/e2b/resolved_domains.py index 39cd178047..df5de5f273 100644 --- a/packages/python-sdk/e2b/resolved_domains.py +++ b/packages/python-sdk/e2b/resolved_domains.py @@ -1,59 +1,37 @@ -"""The proxy reports an API key's control plane in ``X-E2B-Alt-Used``. +"""The API proxy reports a control-plane host in ``X-E2B-Alt-Used``. -Eligibility compares ``api_url`` because rebuilt configs carry it; setting -E2B_DOMAIN, E2B_API_URL, E2B_SANDBOX_URL, or E2B_VOLUME_API_URL disables -discovery. +Discovery is cached per API key and only applies when domain, api_url, and +sandbox_url are unset and debug is off. """ -import os import re from threading import Lock from typing import Optional ALT_USED_HEADER = "X-E2B-Alt-Used" -_resolved_domains: dict[tuple[str, str], str] = {} +_resolved_domains: dict[str, str] = {} _resolved_domains_lock = Lock() -_PINNED_ENV_VARS = ( - "E2B_DOMAIN", - "E2B_API_URL", - "E2B_SANDBOX_URL", - "E2B_VOLUME_API_URL", -) _hostname_pattern = re.compile( r"^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$" ) -def _cache_key(domain: str, api_key: Optional[str]) -> tuple[str, str]: - return domain, api_key if api_key is not None else "" - - -def get_resolved_domain(domain: str, api_key: Optional[str] = None) -> Optional[str]: +def get_resolved_domain(api_key: Optional[str] = None) -> Optional[str]: with _resolved_domains_lock: - return _resolved_domains.get(_cache_key(domain, api_key)) + return _resolved_domains.get(api_key or "") -def record_resolved_domain( - domain: str, api_key: Optional[str], value: Optional[str] -) -> None: +def record_resolved_domain(api_key: Optional[str], value: Optional[str]) -> None: if value is None: return resolved = value.strip().lower() - if not resolved or not _hostname_pattern.fullmatch(resolved) or resolved == domain: + if not resolved or not _hostname_pattern.fullmatch(resolved): return with _resolved_domains_lock: - _resolved_domains[_cache_key(domain, api_key)] = resolved - - -def uses_domain_api_url(domain: str, api_url: str, debug: bool) -> bool: - return ( - not debug - and not any(os.getenv(name) for name in _PINNED_ENV_VARS) - and api_url == f"https://api.{domain}" - ) + _resolved_domains[api_key or ""] = resolved def clear_resolved_domains() -> None: diff --git a/packages/python-sdk/tests/test_logging_option.py b/packages/python-sdk/tests/test_logging_option.py index 301a2d7782..b9ec555544 100644 --- a/packages/python-sdk/tests/test_logging_option.py +++ b/packages/python-sdk/tests/test_logging_option.py @@ -72,7 +72,12 @@ def test_api_client_uses_config_logger(): client.get_httpx_client().close() -def test_api_client_without_logger_registers_only_resolved_domain_hook(): +def test_api_client_without_logger_registers_only_resolved_domain_hook( + monkeypatch, +): + for name in ("E2B_DOMAIN", "E2B_API_URL", "E2B_SANDBOX_URL", "E2B_DEBUG"): + monkeypatch.delenv(name, raising=False) + config = ConnectionConfig(api_key="e2b_" + "0" * 40) client = ApiClient(config) try: diff --git a/packages/python-sdk/tests/test_resolved_domains.py b/packages/python-sdk/tests/test_resolved_domains.py index 9d2f20da7e..1e79e686ba 100644 --- a/packages/python-sdk/tests/test_resolved_domains.py +++ b/packages/python-sdk/tests/test_resolved_domains.py @@ -1,25 +1,45 @@ import httpx import pytest +from e2b import AsyncSandbox, Sandbox from e2b.api import ApiClient, AsyncApiClient from e2b.connection_config import ConnectionConfig -from e2b.resolved_domains import ALT_USED_HEADER, clear_resolved_domains +from e2b.resolved_domains import ( + ALT_USED_HEADER, + clear_resolved_domains, + get_resolved_domain, + record_resolved_domain, +) API_KEY_A = "e2b_" + "a" * 40 API_KEY_B = "e2b_" + "b" * 40 -ORIGINAL_DOMAIN = "e2b.dev" +DEFAULT_DOMAIN = "e2b.app" RESOLVED_DOMAIN = "e2b-juliett.dev" -PINNED_ENV_VALUES = ( - ("E2B_DOMAIN", "e2b.dev"), - ("E2B_API_URL", "https://api.e2b.dev"), - ("E2B_SANDBOX_URL", "https://sandbox.example.com"), - ("E2B_VOLUME_API_URL", "https://api.e2b.dev"), +INELIGIBLE_CONFIGS = ( + ("opts.domain", {"domain": DEFAULT_DOMAIN}, None), + ("opts.api_url", {"api_url": "https://api.custom.example"}, None), + ("opts.sandbox_url", {"sandbox_url": "https://sandbox.example"}, None), + ("E2B_DOMAIN", {}, ("E2B_DOMAIN", "e2b.custom")), + ("E2B_API_URL", {}, ("E2B_API_URL", "https://api.custom.example")), + ( + "E2B_SANDBOX_URL", + {}, + ("E2B_SANDBOX_URL", "https://sandbox.example"), + ), + ("debug option", {"debug": True}, None), + ("E2B_DEBUG", {}, ("E2B_DEBUG", "true")), +) +TEST_ENV_VARS = ( + "E2B_DOMAIN", + "E2B_API_URL", + "E2B_SANDBOX_URL", + "E2B_DEBUG", ) @pytest.fixture(autouse=True) def reset_resolved_domains(monkeypatch): - for name, _ in PINNED_ENV_VALUES: + for name in TEST_ENV_VARS: monkeypatch.delenv(name, raising=False) clear_resolved_domains() yield @@ -56,104 +76,56 @@ def handler(request): @pytest.mark.asyncio -async def test_resolved_domain_is_cached_per_original_domain_and_api_key( - async_mode, -): +async def test_default_config_caches_domain_by_api_key(async_mode): requested_hosts = [] - original = ConnectionConfig(domain=ORIGINAL_DOMAIN, api_key=API_KEY_A, debug=False) - await send_request(original, async_mode, RESOLVED_DOMAIN, requested_hosts) + await send_request( + ConnectionConfig(api_key=API_KEY_A), + async_mode, + RESOLVED_DOMAIN, + requested_hosts, + ) - resolved = ConnectionConfig(domain=ORIGINAL_DOMAIN, api_key=API_KEY_A, debug=False) + resolved = ConnectionConfig(api_key=API_KEY_A) assert resolved.domain == RESOLVED_DOMAIN assert resolved.api_url == f"https://api.{RESOLVED_DOMAIN}" + assert get_resolved_domain(API_KEY_A) == RESOLVED_DOMAIN await send_request(resolved, async_mode, RESOLVED_DOMAIN, requested_hosts) assert requested_hosts == [ - f"api.{ORIGINAL_DOMAIN}", + f"api.{DEFAULT_DOMAIN}", f"api.{RESOLVED_DOMAIN}", ] - assert ( - ConnectionConfig(domain=ORIGINAL_DOMAIN, api_key=API_KEY_B, debug=False).domain - == ORIGINAL_DOMAIN - ) - assert ( - ConnectionConfig(domain="other.dev", api_key=API_KEY_A, debug=False).domain - == "other.dev" - ) + assert ConnectionConfig(api_key=API_KEY_B).domain == DEFAULT_DOMAIN @pytest.mark.asyncio -async def test_custom_api_url_and_debug_do_not_record(async_mode): - requested_hosts = [] - await send_request( - ConnectionConfig( - domain=ORIGINAL_DOMAIN, - api_key=API_KEY_A, - api_url="https://proxy.example", - debug=False, - ), - async_mode, - RESOLVED_DOMAIN, - requested_hosts, - ) - await send_request( - ConnectionConfig(domain=ORIGINAL_DOMAIN, api_key=API_KEY_A, debug=True), - async_mode, - RESOLVED_DOMAIN, - requested_hosts, - ) - - unchanged = ConnectionConfig(domain=ORIGINAL_DOMAIN, api_key=API_KEY_A, debug=False) - assert unchanged.domain == ORIGINAL_DOMAIN - assert unchanged.api_url == f"https://api.{ORIGINAL_DOMAIN}" - assert requested_hosts == ["proxy.example", "localhost"] - +@pytest.mark.parametrize(("name", "options", "env"), INELIGIBLE_CONFIGS) +async def test_ineligible_config_does_not_record_or_switch( + async_mode, monkeypatch, name, options, env +): + if env: + monkeypatch.setenv(*env) -@pytest.mark.asyncio -async def test_explicit_domain_api_url_and_rebuilt_config_are_switched(async_mode): + ineligible = ConnectionConfig(api_key=API_KEY_A, **options) + assert ineligible._discover_domain is False requested_hosts = [] - original = ConnectionConfig( - domain=ORIGINAL_DOMAIN, - api_key=API_KEY_A, - api_url=f"https://api.{ORIGINAL_DOMAIN}", - debug=False, - ) - await send_request(original, async_mode, RESOLVED_DOMAIN, requested_hosts) - - rebuilt = ConnectionConfig(**original.get_api_params()) - assert rebuilt.domain == RESOLVED_DOMAIN - assert rebuilt.api_url == f"https://api.{RESOLVED_DOMAIN}" + await send_request(ineligible, async_mode, RESOLVED_DOMAIN, requested_hosts) + if env: + monkeypatch.delenv(env[0], raising=False) + assert ConnectionConfig(api_key=API_KEY_A).domain == DEFAULT_DOMAIN + assert get_resolved_domain(API_KEY_A) is None -@pytest.mark.asyncio -@pytest.mark.parametrize(("name", "value"), PINNED_ENV_VALUES) -async def test_pinned_environment_disables_domain_discovery( - async_mode, monkeypatch, name, value -): - monkeypatch.setenv(name, value) - requested_hosts = [] - api_url = f"https://api.{ORIGINAL_DOMAIN}" - original = ConnectionConfig( - domain=ORIGINAL_DOMAIN, - api_key=API_KEY_A, - api_url=api_url, - debug=False, - ) - await send_request(original, async_mode, RESOLVED_DOMAIN, requested_hosts) - - unchanged = ConnectionConfig( - domain=ORIGINAL_DOMAIN, - api_key=API_KEY_A, - api_url=api_url, - debug=False, + clear_resolved_domains() + record_resolved_domain(API_KEY_A, RESOLVED_DOMAIN) + if env: + monkeypatch.setenv(*env) + cached_config = ConnectionConfig(api_key=API_KEY_A, **options) + expected_domain = options.get("domain") or ( + env[1] if env and env[0] == "E2B_DOMAIN" else DEFAULT_DOMAIN ) - assert unchanged.domain == ORIGINAL_DOMAIN - await send_request(unchanged, async_mode, RESOLVED_DOMAIN, requested_hosts) - assert requested_hosts == [ - f"api.{ORIGINAL_DOMAIN}", - f"api.{ORIGINAL_DOMAIN}", - ] + assert cached_config.domain == expected_domain @pytest.mark.asyncio @@ -164,18 +136,83 @@ async def test_pinned_environment_disables_domain_discovery( "e2b-juliett.dev:443", "a b", "", - ORIGINAL_DOMAIN, ), ) -async def test_invalid_or_unchanged_domains_are_ignored(async_mode, header_value): +async def test_malformed_response_domains_are_ignored(async_mode, header_value): requested_hosts = [] await send_request( - ConnectionConfig(domain=ORIGINAL_DOMAIN, api_key=API_KEY_A, debug=False), + ConnectionConfig(api_key=API_KEY_A), async_mode, header_value, requested_hosts, ) - unchanged = ConnectionConfig(domain=ORIGINAL_DOMAIN, api_key=API_KEY_A, debug=False) - assert unchanged.domain == ORIGINAL_DOMAIN - assert unchanged.api_url == f"https://api.{ORIGINAL_DOMAIN}" + unchanged = ConnectionConfig(api_key=API_KEY_A) + assert unchanged.domain == DEFAULT_DOMAIN + assert unchanged.api_url == f"https://api.{DEFAULT_DOMAIN}" + + +@pytest.mark.asyncio +async def test_valid_header_matching_default_domain_is_cached(async_mode): + await send_request( + ConnectionConfig(api_key=API_KEY_A), + async_mode, + DEFAULT_DOMAIN, + [], + ) + assert get_resolved_domain(API_KEY_A) == DEFAULT_DOMAIN + + +@pytest.mark.asyncio +async def test_sandbox_create_uses_cached_domain(async_mode, monkeypatch): + requested_hosts = [] + clients = [] + + def handler(request): + requested_hosts.append(request.url.host) + return httpx.Response( + 201, + headers={ALT_USED_HEADER: RESOLVED_DOMAIN}, + json={ + "templateID": "base", + "sandboxID": "sandbox-id", + "clientID": "client-id", + "envdVersion": "0.4.0", + "domain": None, + }, + ) + + def make_sync_client(config, **kwargs): + client = ApiClient(config, transport=httpx.MockTransport(handler)) + clients.append(client) + return client + + def make_async_client(config, **kwargs): + client = AsyncApiClient(config, transport=httpx.MockTransport(handler)) + clients.append(client) + return client + + monkeypatch.setattr("e2b.sandbox_sync.sandbox_api.get_api_client", make_sync_client) + monkeypatch.setattr( + "e2b.sandbox_async.sandbox_api.get_api_client", make_async_client + ) + + try: + if async_mode: + await AsyncSandbox.create(api_key=API_KEY_A) + sandbox = await AsyncSandbox.create(api_key=API_KEY_A) + else: + Sandbox.create(api_key=API_KEY_A) + sandbox = Sandbox.create(api_key=API_KEY_A) + + assert sandbox.sandbox_domain == RESOLVED_DOMAIN + assert requested_hosts == [ + f"api.{DEFAULT_DOMAIN}", + f"api.{RESOLVED_DOMAIN}", + ] + finally: + for client in clients: + if async_mode: + await client.get_async_httpx_client().aclose() + else: + client.get_httpx_client().close() From 81e4abd497a609b0357ea12811367c0f10349fe5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:09:29 +0000 Subject: [PATCH 6/6] style(python-sdk): revert api_url docstring churn Co-Authored-By: mish@e2b.dev --- packages/python-sdk/e2b/connection_config.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 5b0a681da8..cea04db544 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -110,8 +110,7 @@ class ApiParams(TypedDict, total=False): ``X-E2B-Alt-Used``.""" api_url: Optional[str] - """URL to use for the API, defaults to `https://api.`. For internal use only. - """ + """URL to use for the API, defaults to `https://api.`. For internal use only.""" debug: Optional[bool] """Whether to use debug mode, defaults to `E2B_DEBUG` environment variable."""