Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
868bc1f
feat(sdks): retry rate-limited requests
nalekseev-e2b Sep 8, 2026
2f4b974
fix(sdks): limit rate-limit retries to control plane
nalekseev-e2b Sep 8, 2026
d6d6fe9
refactor(sdks): centralize default retry count
nalekseev-e2b Sep 8, 2026
211b42a
refactor(python): align retry module naming
nalekseev-e2b Sep 8, 2026
943e038
fix(sdks): address retry review feedback
nalekseev-e2b Sep 8, 2026
bc53da6
fix(python): preserve retry phase timeouts
nalekseev-e2b Sep 8, 2026
af18843
fix(sdks): bound retries without request timeout
nalekseev-e2b Sep 8, 2026
6ee54bf
refactor(python): centralize retry transport wrapping
nalekseev-e2b Sep 9, 2026
cf2cad6
Merge remote-tracking branch 'origin/main' into feat/retry-after-rate…
nalekseev-e2b Sep 9, 2026
1ea931a
chore(python): refresh code interpreter lockfile
nalekseev-e2b Sep 9, 2026
b755ea3
refactor(js): centralize retry fetch wrapping
nalekseev-e2b Sep 9, 2026
a1999e5
refactor(python): scope retries to API clients
nalekseev-e2b Sep 9, 2026
7bce729
fix(js): replay rate-limited requests without cloning bodies
nalekseev-e2b Sep 10, 2026
f1db259
Merge branch 'main' into feat/retry-after-rate-limits
nalekseev-e2b Sep 10, 2026
378aadc
test(js): preserve runtime-specific request credentials
nalekseev-e2b Sep 10, 2026
e9baad2
chore: stack JS retries on the Python implementation
nalekseev-e2b Sep 10, 2026
c64e32d
refactor(js): gate retries on body streamability and replay via clone
mishushakov Sep 10, 2026
acb9c72
refactor(js): drop RetryableRequest, clone requests for replay
mishushakov Sep 10, 2026
ad59f73
chore(js): drop redundant RequestInfo cast
mishushakov Sep 10, 2026
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
5 changes: 5 additions & 0 deletions .changeset/retry-after-rate-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"e2b": patch
---

Retry control-plane HTTP requests up to three times after `429` responses using the server's delta-seconds `Retry-After` delay. Retries can be configured or disabled with `retries`, and stop when waiting would exhaust the request timeout. Envd requests, including filesystem operations, and volume-content requests are not retried.
7 changes: 6 additions & 1 deletion packages/js-sdk/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
SandboxError,
} from '../errors'
import { createApiLogger } from '../logs'
import { withRateLimitRetry } from '../retry'

/**
* Map an API error code and message to the matching error class — the same
Expand Down Expand Up @@ -105,7 +106,11 @@ class ApiClient {

this.api = createClient<paths>({
baseUrl: config.apiUrl,
fetch: createApiFetch(config.proxy),
fetch: withRateLimitRetry(
createApiFetch(config.proxy),
config.retries,
config.requestTimeoutMs
),
// In HTTP 1.1, all connections are considered persistent unless declared otherwise
// keepalive: true,
headers: {
Expand Down
13 changes: 13 additions & 0 deletions packages/js-sdk/src/connectionConfig.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Logger } from './logs'
import { getEnvVar, version } from './api/metadata'
import { runtime } from './utils'
import { resolveRetries } from './retry'

// Remove once all deployments support sandbox subdomains
const supportedDomains = ['e2b.app', 'e2b.dev', 'e2b.pro', 'e2b-staging.dev']

export const REQUEST_TIMEOUT_MS = 60_000 // 60 seconds
export const DEFAULT_RETRIES = 3
export const DEFAULT_SANDBOX_TIMEOUT_MS = 300_000 // 300 seconds
export const KEEPALIVE_PING_INTERVAL_SEC = 50 // 50 seconds

Expand Down Expand Up @@ -58,6 +60,15 @@ export interface ConnectionOpts {
* @default 60_000 // 60 seconds
*/
requestTimeoutMs?: number
/**
* Number of control-plane API retries after a 429 response with a valid,
* non-negative integer delta-seconds `Retry-After` header. HTTP-date and
* malformed values are not retried.
* Retry waits use a 60-second total limit when request timeouts are disabled.
*
* @default 3
*/
retries?: number
/**
* Logger to use for logging messages. It can accept any object that implements `Logger` interface—for example, {@link console}.
*/
Expand Down Expand Up @@ -408,6 +419,7 @@ export class ConnectionConfig {
readonly logger?: Logger

readonly requestTimeoutMs: number
readonly retries: number

readonly apiKey?: string
/**
Expand Down Expand Up @@ -435,6 +447,7 @@ export class ConnectionConfig {
this.debug = opts?.debug ?? ConnectionConfig.debug
this.domain = opts?.domain || ConnectionConfig.domain
this.requestTimeoutMs = opts?.requestTimeoutMs ?? REQUEST_TIMEOUT_MS
this.retries = resolveRetries(opts?.retries ?? DEFAULT_RETRIES)
this.logger = opts?.logger
this.requestSource = ConnectionConfig.getRequestSource()
this.headers = { ...(opts?.headers ?? {}), ...(opts?.apiHeaders ?? {}) }
Expand Down
99 changes: 99 additions & 0 deletions packages/js-sdk/src/retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { InvalidArgumentError } from './errors'
import { isReadableStreamLike } from './is'

const MAX_RETRY_AFTER_SECONDS = 2_147_483
const MAX_RETRY_WAIT_WITHOUT_TIMEOUT_MS = 60_000

export function resolveRetries(retries: number): number {
if (!Number.isInteger(retries) || retries < 0) {
throw new InvalidArgumentError(
`Invalid retries=${retries}: expected a non-negative integer.`
)
}
return retries
}

export function parseRetryAfter(
value: string | null | undefined
): number | undefined {
if (!value) return undefined

const trimmed = value.trim()
if (!/^\d+$/.test(trimmed)) return undefined

const delay = Number(trimmed)
return Number.isSafeInteger(delay) && delay <= MAX_RETRY_AFTER_SECONDS
? delay
: undefined
}

type RetryDependencies = {
monotonic?: () => number
sleep?: (delayMs: number, signal: AbortSignal) => Promise<void>
}

function wait(delayMs: number, signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.reject(signal.reason)

return new Promise((resolve, reject) => {
const onAbort = () => {
clearTimeout(timer)
reject(signal.reason)
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, delayMs)
signal.addEventListener('abort', onAbort, { once: true })
})
}

/** Retry replayable requests after a 429 carrying `Retry-After`. */
export function withRateLimitRetry(
fetchImpl: typeof fetch,
retries: number,
requestTimeoutMs: number,
dependencies: RetryDependencies = {}
): typeof fetch {
const monotonic = dependencies.monotonic ?? (() => performance.now())
const sleep = dependencies.sleep ?? wait

return (async (input, init) => {
// Streaming bodies would be consumed by the first attempt and cannot be
// replayed without buffering them, so they get a single attempt.
if (retries === 0 || isReadableStreamLike(init?.body)) {
return fetchImpl(input, init)
}

// Replaying a Request-form input via `clone()` is safe because the only
// producer of those is openapi-fetch, which serializes every body to a
// string before constructing the Request — cloning never tees a live
// stream.
const request =
input instanceof Request && init === undefined
? input
: new Request(input, init)
const deadline =
monotonic() + (requestTimeoutMs || MAX_RETRY_WAIT_WITHOUT_TIMEOUT_MS)

for (let attempt = 0; ; attempt++) {
const response = await fetchImpl(
attempt === retries ? request : request.clone()
)
const retryAfter = parseRetryAfter(response.headers.get('Retry-After'))
const delayMs = retryAfter === undefined ? undefined : retryAfter * 1000

if (
response.status !== 429 ||
delayMs === undefined ||
attempt === retries ||
monotonic() + delayMs >= deadline
) {
return response
}

await response.body?.cancel().catch(() => {})
await sleep(delayMs, request.signal)
}
}) as typeof fetch
}
1 change: 1 addition & 0 deletions packages/js-sdk/src/sandbox/sandboxApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,7 @@ export interface SandboxApiOpts extends Partial<
| 'debug'
| 'domain'
| 'requestTimeoutMs'
| 'retries'
| 'signal'
>
> {}
Expand Down
47 changes: 47 additions & 0 deletions packages/js-sdk/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,53 @@ test('per-call options take precedence over the client config', async () => {
assert.equal(lastRequest().apiKey, API_KEY_B)
})

test('client retries rate-limited control-plane requests', async () => {
let attempts = 0
server.use(
http.get(/\/v2\/sandboxes/, () => {
attempts++
if (attempts === 1) {
return new HttpResponse(null, {
status: 429,
headers: { 'Retry-After': '0' },
})
}
return HttpResponse.json([])
})
)
const client = new E2B({
apiKey: API_KEY_A,
domain: DOMAIN_A,
})

await client.Sandbox.list().nextItems()

assert.equal(attempts, 2)
})

test('client replays serialized control-plane JSON after a rate limit', async () => {
const bodies: unknown[] = []
server.use(
http.post(/\/sandboxes$/, async ({ request }) => {
bodies.push(await request.json())
if (bodies.length === 1) {
return new HttpResponse(null, {
status: 429,
headers: { 'Retry-After': '0' },
})
}
return HttpResponse.json(sandboxResponse)
})
)
const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A })

await client.Sandbox.create()

expect(bodies).toHaveLength(2)
expect(bodies[0]).toMatchObject({ templateID: 'base' })
expect(bodies[1]).toEqual(bodies[0])
})

test('client.Sandbox can be rebound to a variable', async () => {
const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A })
const S = client.Sandbox
Expand Down
7 changes: 7 additions & 0 deletions packages/js-sdk/tests/connectionConfig.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { assert, test, beforeEach, afterEach } from 'vitest'
import {
ConnectionConfig,
DEFAULT_RETRIES,
setupRequestController,
wrapStreamWithConnectionCleanup,
} from '../src/connectionConfig'
Expand Down Expand Up @@ -42,6 +43,12 @@ test('api_url defaults correctly', () => {
assert.equal(config.apiUrl, 'https://api.e2b.app')
})

test('retries default to three and accept a non-negative integer', () => {
assert.equal(new ConnectionConfig().retries, DEFAULT_RETRIES)
assert.equal(new ConnectionConfig({ retries: 2 }).retries, 2)
assert.throws(() => new ConnectionConfig({ retries: -1 }))
})

test('api_url in args', () => {
const config = new ConnectionConfig({ apiUrl: 'http://localhost:8080' })
assert.equal(config.apiUrl, 'http://localhost:8080')
Expand Down
Loading
Loading