diff --git a/.changeset/friendly-planes-speak.md b/.changeset/friendly-planes-speak.md new file mode 100644 index 0000000000..5d268283b8 --- /dev/null +++ b/.changeset/friendly-planes-speak.md @@ -0,0 +1,8 @@ +--- +'e2b': minor +'@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 API key). +- The sandbox domain falls back to the discovered domain, so envd traffic reaches the right plane. +- 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 23d9a06307..fd6cbd5080 100644 --- a/packages/js-sdk/src/api/index.ts +++ b/packages/js-sdk/src/api/index.ts @@ -12,6 +12,7 @@ import { } from '../errors' import { createApiLogger } from '../logs' import { withRetry } from '../retry' +import { ALT_USED_HEADER, recordResolvedDomain } from '../resolvedDomains' /** * Map an API error code and message to the matching error class — the same @@ -104,13 +105,26 @@ class ApiClient { ) } + const fetchWithRetry = withRetry( + createApiFetch(config), + config.retries, + config.requestTimeoutMs + ) + + const apiFetch: typeof fetch = async (input, init) => { + const response = await fetchWithRetry(input, init) + if (config.discoverDomain) { + recordResolvedDomain( + config.apiKey, + response.headers.get(ALT_USED_HEADER) + ) + } + return response + } + this.api = createClient({ baseUrl: config.apiUrl, - fetch: withRetry( - createApiFetch(config), - config.retries, - config.requestTimeoutMs - ), + fetch: apiFetch, // 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..79c7ebfd4e 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 } from './resolvedDomains' // Remove once all deployments support sandbox subdomains const supportedDomains = ['e2b.app', 'e2b.dev', 'e2b.pro', 'e2b-staging.dev'] @@ -57,6 +58,8 @@ export interface ConnectionOpts { /** * Domain to use for the API. * + * 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 @@ -442,6 +445,8 @@ export class ConnectionConfig { } readonly debug: boolean + /** @internal */ + readonly discoverDomain: boolean readonly domain: string readonly apiUrl: string readonly sandboxUrl?: string @@ -466,7 +471,17 @@ 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 + 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) this.logger = opts?.logger @@ -475,10 +490,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 = this.discoverDomain + ? getResolvedDomain(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..270f3799f8 --- /dev/null +++ b/packages/js-sdk/src/resolvedDomains.ts @@ -0,0 +1,33 @@ +// 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 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(apiKey?: string) { + return apiKey ?? '' +} + +export function getResolvedDomain(apiKey?: string): string | undefined { + return resolvedDomains.get(cacheKey(apiKey)) +} + +export function recordResolvedDomain( + apiKey: string | undefined, + value: string | null | undefined +) { + const resolved = value?.trim().toLowerCase() + if (!resolved || !hostnamePattern.test(resolved)) { + return + } + + resolvedDomains.set(cacheKey(apiKey), resolved) +} + +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..2c9c365324 --- /dev/null +++ b/packages/js-sdk/tests/resolvedDomains.test.ts @@ -0,0 +1,198 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from 'vitest' +import { http, HttpResponse } from 'msw' + +import { ApiClient, ConnectionConfig, Sandbox } from '../src' +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 DEFAULT_DOMAIN = 'e2b.app' +const RESOLVED_DOMAIN = 'e2b-juliett.dev' +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, status = 200) { + return HttpResponse.json(body, { + status, + headers: + responseHeader === null + ? undefined + : { [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', + clientID: 'client-id', + domain: null, + envdVersion: '0.4.0', + }, + 201 + ) + }) +) + +beforeAll(async () => { + await server.listen({ onUnhandledRequest: 'error' }) +}) + +afterAll(async () => { + await server.close() +}) + +beforeEach(() => { + clearResolvedDomains() + requestedHosts.length = 0 + responseHeader = RESOLVED_DOMAIN + for (const name of [ + 'E2B_DOMAIN', + 'E2B_API_URL', + 'E2B_SANDBOX_URL', + 'E2B_DEBUG', + ]) { + vi.stubEnv(name, '') + } + server.resetHandlers() +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +async function sendRequest(config: ConnectionConfig) { + await new ApiClient(config).api.GET('/v2/sandboxes') +} + +describe('resolved domains', () => { + 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.${DEFAULT_DOMAIN}`, + `api.${RESOLVED_DOMAIN}`, + ]) + }) + + test('does not reuse a resolved domain for a different API key', async () => { + await sendRequest(new ConnectionConfig({ apiKey: API_KEY_A })) + + const otherKey = new ConnectionConfig({ apiKey: API_KEY_B }) + expect(otherKey.domain).toBe(DEFAULT_DOMAIN) + expect(otherKey.apiUrl).toBe(`https://api.${DEFAULT_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, + ...(options ?? {}), + }) + 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 + ) + + clearResolvedDomains() + recordResolvedDomain(API_KEY_A, RESOLVED_DOMAIN) + if (env) { + vi.stubEnv(env[0], env[1]) + } + const cachedConfig = new ConnectionConfig({ + apiKey: API_KEY_A, + ...(options ?? {}), + }) + 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', ''])( + '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}`) + } + ) + + 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 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 68f7d9f243..8c346c4100 100644 --- a/packages/python-sdk/e2b/api/__init__.py +++ b/packages/python-sdk/e2b/api/__init__.py @@ -27,6 +27,10 @@ ServiceBusyException, SandboxException, ) +from e2b.resolved_domains import ( + ALT_USED_HEADER, + record_resolved_domain, +) def encode_path_param(value: str) -> str: @@ -252,6 +256,8 @@ def __init__( prefix = "" self._logger = config.logger + self._api_key = config.api_key + self._should_record_domain = config._discover_domain headers = { **default_headers, @@ -266,9 +272,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 +306,32 @@ 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._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._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..cea04db544 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 ProxyTypes = Union[str, httpx.URL, httpx.Proxy] """The forms the ``proxy`` option accepts: a URL string, an ``httpx.URL``, or @@ -101,7 +102,12 @@ 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 ``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.""" @@ -279,8 +285,16 @@ def __init__( http_version: Optional[HttpVersion] = None, ): self.logger = logger - self.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 {})} @@ -300,15 +314,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.{self.domain}") + self.api_url = configured_api_url or ( + "http://localhost:3000" if self.debug else f"https://api.{domain}" ) - - self._sandbox_url: Optional[str] = ( - sandbox_url or ConnectionConfig._sandbox_url() + resolved_domain = ( + 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] = 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 new file mode 100644 index 0000000000..df5de5f273 --- /dev/null +++ b/packages/python-sdk/e2b/resolved_domains.py @@ -0,0 +1,39 @@ +"""The API proxy reports a control-plane host in ``X-E2B-Alt-Used``. + +Discovery is cached per API key and only applies when domain, api_url, and +sandbox_url are unset and debug is off. +""" + +import re +from threading import Lock +from typing import Optional + +ALT_USED_HEADER = "X-E2B-Alt-Used" + +_resolved_domains: dict[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 get_resolved_domain(api_key: Optional[str] = None) -> Optional[str]: + with _resolved_domains_lock: + return _resolved_domains.get(api_key or "") + + +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): + return + + with _resolved_domains_lock: + _resolved_domains[api_key or ""] = resolved + + +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..b9ec555544 100644 --- a/packages/python-sdk/tests/test_logging_option.py +++ b/packages/python-sdk/tests/test_logging_option.py @@ -72,17 +72,19 @@ 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( + 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: 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..1e79e686ba --- /dev/null +++ b/packages/python-sdk/tests/test_resolved_domains.py @@ -0,0 +1,218 @@ +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, + get_resolved_domain, + record_resolved_domain, +) + +API_KEY_A = "e2b_" + "a" * 40 +API_KEY_B = "e2b_" + "b" * 40 +DEFAULT_DOMAIN = "e2b.app" +RESOLVED_DOMAIN = "e2b-juliett.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 TEST_ENV_VARS: + monkeypatch.delenv(name, raising=False) + 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_default_config_caches_domain_by_api_key(async_mode): + requested_hosts = [] + await send_request( + ConnectionConfig(api_key=API_KEY_A), + async_mode, + RESOLVED_DOMAIN, + requested_hosts, + ) + + 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.{DEFAULT_DOMAIN}", + f"api.{RESOLVED_DOMAIN}", + ] + + assert ConnectionConfig(api_key=API_KEY_B).domain == DEFAULT_DOMAIN + + +@pytest.mark.asyncio +@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) + + ineligible = ConnectionConfig(api_key=API_KEY_A, **options) + assert ineligible._discover_domain is False + requested_hosts = [] + 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 + + 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 cached_config.domain == expected_domain + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "header_value", + ( + "https://e2b-juliett.dev", + "e2b-juliett.dev:443", + "a b", + "", + ), +) +async def test_malformed_response_domains_are_ignored(async_mode, header_value): + requested_hosts = [] + await send_request( + ConnectionConfig(api_key=API_KEY_A), + async_mode, + header_value, + requested_hosts, + ) + + 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()