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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/friendly-planes-speak.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 19 additions & 5 deletions packages/js-sdk/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
} 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
Expand All @@ -36,7 +37,7 @@

if (code === 429) {
const message = 'Rate limit exceeded, please try again later'
return new RateLimitError(content ? `${message} - ${content}` : message)

Check failure on line 40 in packages/js-sdk/src/api/index.ts

View workflow job for this annotation

GitHub Actions / Staging / JS SDK Tests / JS SDK - node (windows-latest)

[template] tests/template/methods/runCmd.test.ts > run command as a different user

RateLimitError: Rate limit exceeded, please try again later - you have reached the maximum number of concurrent template builds (50). Please wait for existing builds to complete or contact support if you need more concurrent builds. ❯ apiErrorFromCode src/api/index.ts:40:12 ❯ handleApiError src/api/index.ts:72:12 ❯ requestBuild src/template/buildApi.ts:68:17 ❯ TemplateBase.build src/template/index.ts:1074:9 ❯ Proxy.build src/template/index.ts:179:20 ❯ buildTemplate tests/setup.ts:54:12 ❯ tests/template/methods/runCmd.test.ts:22:5 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { statusCode: 429 }

Check failure on line 40 in packages/js-sdk/src/api/index.ts

View workflow job for this annotation

GitHub Actions / Staging / JS SDK Tests / JS SDK - node (windows-latest)

[template] tests/template/methods/runCmd.test.ts > run command

RateLimitError: Rate limit exceeded, please try again later - you have reached the maximum number of concurrent template builds (50). Please wait for existing builds to complete or contact support if you need more concurrent builds. ❯ apiErrorFromCode src/api/index.ts:40:12 ❯ handleApiError src/api/index.ts:72:12 ❯ requestBuild src/template/buildApi.ts:68:17 ❯ TemplateBase.build src/template/index.ts:1074:9 ❯ Proxy.build src/template/index.ts:179:20 ❯ buildTemplate tests/setup.ts:54:12 ❯ tests/template/methods/runCmd.test.ts:11:3 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { statusCode: 429 }

Check failure on line 40 in packages/js-sdk/src/api/index.ts

View workflow job for this annotation

GitHub Actions / Staging / JS SDK Tests / JS SDK - node (windows-latest)

[template] tests/template/backgroundBuild.test.ts > build template in background

RateLimitError: Rate limit exceeded, please try again later - you have reached the maximum number of concurrent template builds (50). Please wait for existing builds to complete or contact support if you need more concurrent builds. ❯ apiErrorFromCode src/api/index.ts:40:12 ❯ handleApiError src/api/index.ts:72:12 ❯ requestBuild src/template/buildApi.ts:68:17 ❯ TemplateBase.build src/template/index.ts:1074:9 ❯ tests/template/backgroundBuild.test.ts:14:21 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { statusCode: 429 }

Check failure on line 40 in packages/js-sdk/src/api/index.ts

View workflow job for this annotation

GitHub Actions / Staging / JS SDK Tests / JS SDK - node (ubuntu-22.04)

[template] tests/template/methods/runCmd.test.ts > run command as a different user

RateLimitError: Rate limit exceeded, please try again later - you have reached the maximum number of concurrent template builds (50). Please wait for existing builds to complete or contact support if you need more concurrent builds. ❯ apiErrorFromCode src/api/index.ts:40:12 ❯ handleApiError src/api/index.ts:72:12 ❯ requestBuild src/template/buildApi.ts:68:17 ❯ TemplateBase.build src/template/index.ts:1074:9 ❯ Proxy.build src/template/index.ts:179:20 ❯ buildTemplate tests/setup.ts:54:12 ❯ tests/template/methods/runCmd.test.ts:22:5 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { statusCode: 429 }

Check failure on line 40 in packages/js-sdk/src/api/index.ts

View workflow job for this annotation

GitHub Actions / Staging / JS SDK Tests / JS SDK - node (ubuntu-22.04)

[template] tests/template/methods/runCmd.test.ts > run command

RateLimitError: Rate limit exceeded, please try again later - you have reached the maximum number of concurrent template builds (50). Please wait for existing builds to complete or contact support if you need more concurrent builds. ❯ apiErrorFromCode src/api/index.ts:40:12 ❯ handleApiError src/api/index.ts:72:12 ❯ requestBuild src/template/buildApi.ts:68:17 ❯ TemplateBase.build src/template/index.ts:1074:9 ❯ Proxy.build src/template/index.ts:179:20 ❯ buildTemplate tests/setup.ts:54:12 ❯ tests/template/methods/runCmd.test.ts:11:3 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { statusCode: 429 }
}

if (code === 503) {
Expand Down Expand Up @@ -104,13 +105,26 @@
)
}

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<paths>({
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: {
Expand Down
26 changes: 23 additions & 3 deletions packages/js-sdk/src/connectionConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -442,6 +445,8 @@ export class ConnectionConfig {
}

readonly debug: boolean
/** @internal */
readonly discoverDomain: boolean
readonly domain: string
readonly apiUrl: string
readonly sandboxUrl?: string
Expand All @@ -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
Expand All @@ -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
}
Expand Down
33 changes: 33 additions & 0 deletions packages/js-sdk/src/resolvedDomains.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>()
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()
}
198 changes: 198 additions & 0 deletions packages/js-sdk/tests/resolvedDomains.test.ts
Original file line number Diff line number Diff line change
@@ -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}`,
])
})
})
Loading
Loading