From 868bc1fad52bd9db477d4ba0225ad52a3fcf0443 Mon Sep 17 00:00:00 2001 From: nalekseev-e2b Date: Tue, 8 Sep 2026 17:14:43 +0200 Subject: [PATCH 01/16] feat(sdks): retry rate-limited requests --- .changeset/retry-after-rate-limits.md | 6 + packages/js-sdk/src/api/index.ts | 7 +- packages/js-sdk/src/connectionConfig.ts | 9 + packages/js-sdk/src/envd/api.ts | 37 +- packages/js-sdk/src/retry.ts | 84 +++ .../js-sdk/src/sandbox/filesystem/index.ts | 22 +- packages/js-sdk/src/sandbox/index.ts | 9 +- packages/js-sdk/src/volume/client.ts | 17 +- packages/js-sdk/src/volume/index.ts | 17 +- packages/js-sdk/tests/client.test.ts | 25 + .../js-sdk/tests/connectionConfig.test.ts | 6 + packages/js-sdk/tests/retry.test.ts | 154 ++++++ .../tests/sandbox/configPropagation.test.ts | 2 + packages/js-sdk/tests/volume/file.test.ts | 75 ++- packages/python-sdk/e2b/_retry.py | 191 +++++++ .../e2b/api/client_async/__init__.py | 15 +- .../e2b/api/client_sync/__init__.py | 15 +- packages/python-sdk/e2b/connection_config.py | 10 + .../sandbox_async/filesystem/filesystem.py | 7 +- .../e2b/sandbox_sync/filesystem/filesystem.py | 7 +- .../e2b/volume/client_async/__init__.py | 14 +- .../e2b/volume/client_sync/__init__.py | 14 +- .../e2b/volume/connection_config.py | 9 + .../python-sdk/e2b/volume/volume_async.py | 9 + packages/python-sdk/e2b/volume/volume_sync.py | 9 + .../tests/test_api_client_transport.py | 311 +++++++++-- .../tests/test_connection_config.py | 17 + .../tests/test_rate_limit_retry_transport.py | 484 ++++++++++++++++++ .../python-sdk/tests/test_volume_client.py | 129 ++++- .../tests/test_volume_connection_config.py | 22 + 30 files changed, 1609 insertions(+), 124 deletions(-) create mode 100644 .changeset/retry-after-rate-limits.md create mode 100644 packages/js-sdk/src/retry.ts create mode 100644 packages/js-sdk/tests/retry.test.ts create mode 100644 packages/python-sdk/e2b/_retry.py create mode 100644 packages/python-sdk/tests/test_rate_limit_retry_transport.py diff --git a/.changeset/retry-after-rate-limits.md b/.changeset/retry-after-rate-limits.md new file mode 100644 index 0000000000..93b4cc7ffb --- /dev/null +++ b/.changeset/retry-after-rate-limits.md @@ -0,0 +1,6 @@ +--- +"@e2b/python-sdk": patch +"e2b": patch +--- + +Allow HTTP requests to opt into retries after `429` responses using the server's delta-seconds `Retry-After` delay. Retries remain disabled by default, are skipped for streamed uploads, and stop when waiting would exhaust the request timeout. diff --git a/packages/js-sdk/src/api/index.ts b/packages/js-sdk/src/api/index.ts index 8750ff112f..482dc3dc48 100644 --- a/packages/js-sdk/src/api/index.ts +++ b/packages/js-sdk/src/api/index.ts @@ -6,6 +6,7 @@ import { createApiFetch } from './http2' import { ConnectionConfig } from '../connectionConfig' import { AuthenticationError, RateLimitError, 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 @@ -90,7 +91,11 @@ class ApiClient { this.api = createClient({ 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: { diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index c243337bf8..88e656367d 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -1,6 +1,7 @@ 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'] @@ -58,6 +59,12 @@ export interface ConnectionOpts { * @default 60_000 // 60 seconds */ requestTimeoutMs?: number + /** + * Number of retries after a 429 response with `Retry-After`. + * + * @default 0 + */ + retries?: number /** * Logger to use for logging messages. It can accept any object that implements `Logger` interface—for example, {@link console}. */ @@ -408,6 +415,7 @@ export class ConnectionConfig { readonly logger?: Logger readonly requestTimeoutMs: number + readonly retries: number readonly apiKey?: string /** @@ -435,6 +443,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) this.logger = opts?.logger this.requestSource = ConnectionConfig.getRequestSource() this.headers = { ...(opts?.headers ?? {}), ...(opts?.apiHeaders ?? {}) } diff --git a/packages/js-sdk/src/envd/api.ts b/packages/js-sdk/src/envd/api.ts index 673648c886..5ed1b1186d 100644 --- a/packages/js-sdk/src/envd/api.ts +++ b/packages/js-sdk/src/envd/api.ts @@ -1,8 +1,9 @@ import createClient from 'openapi-fetch' import type { components, paths } from './schema.gen' -import { ConnectionConfig } from '../connectionConfig' +import { ConnectionConfig, REQUEST_TIMEOUT_MS } from '../connectionConfig' import { createApiLogger } from '../logs' +import { resolveRetries, withRateLimitRetry } from '../retry' import { SandboxError, InvalidArgumentError, @@ -193,23 +194,31 @@ export async function handleWatchDirStartEvent( class EnvdApiClient { readonly api: ReturnType> readonly version: string + readonly fetch: typeof fetch + + private readonly retries: number + private readonly requestTimeoutMs: number constructor( - config: Pick & { - /** - * Sandbox-scoped envd access token, sent as the `X-Access-Token` header. - */ - envdAccessToken?: string - fetch?: (request: Request) => ReturnType - headers?: Record - }, + config: Pick & + Partial> & { + /** + * Sandbox-scoped envd access token, sent as the `X-Access-Token` header. + */ + envdAccessToken?: string + fetch?: typeof fetch + headers?: Record + }, metadata: { version: string } ) { + this.fetch = config.fetch ?? fetch + this.retries = config.retries ?? 0 + this.requestTimeoutMs = config.requestTimeoutMs ?? REQUEST_TIMEOUT_MS this.api = createClient({ baseUrl: config.apiUrl, - fetch: config?.fetch, + fetch: this.getFetch(), headers: { ...config?.headers, ...(config.envdAccessToken && { @@ -225,6 +234,14 @@ class EnvdApiClient { this.api.use(createApiLogger(config.logger)) } } + + getFetch(retries?: number, requestTimeoutMs?: number): typeof fetch { + return withRateLimitRetry( + this.fetch, + resolveRetries(retries ?? this.retries), + requestTimeoutMs ?? this.requestTimeoutMs + ) + } } export type { components, paths } diff --git a/packages/js-sdk/src/retry.ts b/packages/js-sdk/src/retry.ts new file mode 100644 index 0000000000..d0af634495 --- /dev/null +++ b/packages/js-sdk/src/retry.ts @@ -0,0 +1,84 @@ +const MAX_RETRY_AFTER_SECONDS = 2_147_483 + +export function resolveRetries(retries?: number): number { + if (retries === undefined) return 0 + if (!Number.isInteger(retries) || retries < 0) { + throw new Error( + `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 +} + +function wait(delayMs: number, signal: AbortSignal): Promise { + 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) => { + if (retries === 0) return fetchImpl(input, init) + + const request = new Request(input as RequestInfo, init) + const deadline = requestTimeoutMs + ? monotonic() + requestTimeoutMs + : undefined + + for (let attempt = 0; ; attempt++) { + const response = await fetchImpl(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 || + (deadline !== undefined && monotonic() + delayMs >= deadline) + ) { + return response + } + + await response.body?.cancel().catch(() => {}) + await sleep(delayMs, request.signal) + } + }) as typeof fetch +} diff --git a/packages/js-sdk/src/sandbox/filesystem/index.ts b/packages/js-sdk/src/sandbox/filesystem/index.ts index c07a12ae0b..fd53bc2c33 100644 --- a/packages/js-sdk/src/sandbox/filesystem/index.ts +++ b/packages/js-sdk/src/sandbox/filesystem/index.ts @@ -257,7 +257,7 @@ export function mapEntryInfo(entry: FsEntryInfo): EntryInfo { * Options for the sandbox filesystem operations. */ export interface FilesystemRequestOpts extends Partial< - Pick + Pick > { /** * User to use for the operation in the sandbox. @@ -494,6 +494,7 @@ export class Filesystem { parseAs: 'stream', signal: controller.signal, headers, + fetch: this.envdApi.getFetch(opts?.retries, requestTimeoutMs), }) .catch(async (err) => { // Map a dropped connection during the handshake (e.g. killed @@ -530,6 +531,8 @@ export class Filesystem { } } + const requestTimeoutMs = + opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs const res = await this.envdApi.api .GET('/files', { params: { @@ -539,11 +542,9 @@ export class Filesystem { }, }, parseAs: format === 'bytes' ? 'arrayBuffer' : format, - signal: this.connectionConfig.getSignal( - opts?.requestTimeoutMs, - opts?.signal - ), + signal: this.connectionConfig.getSignal(requestTimeoutMs, opts?.signal), headers, + fetch: this.envdApi.getFetch(opts?.retries, requestTimeoutMs), }) .catch(async (err) => { throw await handleEnvdApiFetchError(err, this.checkHealth) @@ -695,6 +696,9 @@ export class Filesystem { writeOpts?.requestTimeoutMs, writeOpts?.signal ) + const requestTimeoutMs = + writeOpts?.requestTimeoutMs ?? + this.connectionConfig.requestTimeoutMs const res = await this.envdApi.api .POST('/files', { @@ -707,6 +711,9 @@ export class Filesystem { bodySerializer: () => body, headers, signal, + fetch: streamed + ? this.envdApi.fetch + : this.envdApi.getFetch(writeOpts?.retries, requestTimeoutMs), body: {}, // Streaming request bodies require half-duplex mode. ...(streamed && { @@ -750,6 +757,8 @@ export class Filesystem { ) } + const requestTimeoutMs = + writeOpts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs const res = await this.envdApi.api .POST('/files', { params: { @@ -761,9 +770,10 @@ export class Filesystem { bodySerializer: () => formData, headers: extraHeaders, signal: this.connectionConfig.getSignal( - writeOpts?.requestTimeoutMs, + requestTimeoutMs, writeOpts?.signal ), + fetch: this.envdApi.getFetch(writeOpts?.retries, requestTimeoutMs), body: {}, }) .catch(async (err) => { diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index 6dd4557436..94749b474a 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -210,6 +210,8 @@ export class Sandbox extends SandboxApi { ...sandboxHeaders, }, fetch: (request) => envdFetch(request), + retries: this.connectionConfig.retries, + requestTimeoutMs: this.connectionConfig.requestTimeoutMs, }, { version: opts.envdVersion, @@ -551,15 +553,18 @@ export class Sandbox extends SandboxApi { * ``` */ async isRunning( - opts?: Pick + opts?: Pick ): Promise { + const requestTimeoutMs = + opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs const signal = this.connectionConfig.getSignal( - opts?.requestTimeoutMs, + requestTimeoutMs, opts?.signal ) const res = await this.envdApi.api.GET('/health', { signal, + fetch: this.envdApi.getFetch(opts?.retries, requestTimeoutMs), }) if (res.response.status == 502) { diff --git a/packages/js-sdk/src/volume/client.ts b/packages/js-sdk/src/volume/client.ts index d63057b597..f11f6843ec 100644 --- a/packages/js-sdk/src/volume/client.ts +++ b/packages/js-sdk/src/volume/client.ts @@ -5,6 +5,7 @@ import { defaultHeaders, getEnvVar } from '../api/metadata' import { createApiFetch } from '../api/http2' import { buildRequestSignal } from '../connectionConfig' import { createApiLogger, Logger } from '../logs' +import { resolveRetries, withRateLimitRetry } from '../retry' import type { Volume } from './index' const REQUEST_TIMEOUT_MS = 60_000 // 60 seconds @@ -41,6 +42,12 @@ export interface VolumeApiOpts { * @default 60_000 // 60 seconds */ requestTimeoutMs?: number + /** + * Number of retries after a 429 response with `Retry-After`. + * + * @default 0 + */ + retries?: number /** * Logger to use for logging messages. It can accept any object that implements `Logger` interface—for example, {@link console}. */ @@ -74,6 +81,7 @@ export class VolumeConnectionConfig { readonly headers?: Record readonly logger?: Logger readonly requestTimeoutMs: number + readonly retries: number readonly signal?: AbortSignal readonly proxy?: string @@ -88,6 +96,7 @@ export class VolumeConnectionConfig { this.headers = opts?.headers this.logger = opts?.logger this.requestTimeoutMs = opts?.requestTimeoutMs ?? REQUEST_TIMEOUT_MS + this.retries = resolveRetries(opts?.retries ?? volume.retries) this.signal = opts?.signal this.proxy = opts?.proxy || volume.proxy } @@ -117,11 +126,17 @@ export class VolumeConnectionConfig { */ class VolumeApiClient { readonly api: ReturnType> + readonly fetch: typeof fetch constructor(config: VolumeConnectionConfig) { + this.fetch = createApiFetch(config.proxy) this.api = createClient({ baseUrl: config.apiUrl, - fetch: createApiFetch(config.proxy), + fetch: withRateLimitRetry( + this.fetch, + config.retries, + config.requestTimeoutMs + ), headers: { ...defaultHeaders, ...(config.token && { Authorization: `Bearer ${config.token}` }), diff --git a/packages/js-sdk/src/volume/index.ts b/packages/js-sdk/src/volume/index.ts index 4563d0f9d8..75114ade77 100644 --- a/packages/js-sdk/src/volume/index.ts +++ b/packages/js-sdk/src/volume/index.ts @@ -82,6 +82,11 @@ export class Volume extends ClientFactory { */ readonly proxy?: string + /** + * Number of retries after a 429 response with `Retry-After`. + */ + readonly retries?: number + /** * Create a local Volume instance with no API call. * @@ -91,6 +96,7 @@ export class Volume extends ClientFactory { * @param domain domain for the volume API. * @param debug whether to use debug mode. * @param proxy proxy URL for the volume content API. + * @param retries number of retries after rate limiting. */ constructor( volumeId: string, @@ -98,7 +104,8 @@ export class Volume extends ClientFactory { token: string, domain?: string, debug?: boolean, - proxy?: string + proxy?: string, + retries?: number ) { super() this.volumeId = volumeId @@ -107,6 +114,7 @@ export class Volume extends ClientFactory { this.domain = domain this.debug = debug this.proxy = proxy + this.retries = retries } /** @@ -148,7 +156,8 @@ export class Volume extends ClientFactory { res.data.token, res.data.domain || config.domain, config.debug, - config.proxy + config.proxy, + config.retries ) as InstanceType } @@ -174,7 +183,8 @@ export class Volume extends ClientFactory { token, domain ?? config.domain, config.debug, - config.proxy + config.proxy, + config.retries ) as InstanceType } @@ -708,6 +718,7 @@ export class Volume extends ClientFactory { 'Content-Type': 'application/octet-stream', }, signal, + ...(streamed && { fetch: client.fetch }), // Streaming request bodies require half-duplex mode. ...(streamed && { duplex: 'half' as const }), }) diff --git a/packages/js-sdk/tests/client.test.ts b/packages/js-sdk/tests/client.test.ts index bb7f47280c..db210368d4 100644 --- a/packages/js-sdk/tests/client.test.ts +++ b/packages/js-sdk/tests/client.test.ts @@ -167,6 +167,31 @@ 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, + retries: 1, + }) + + await client.Sandbox.list().nextItems() + + assert.equal(attempts, 2) +}) + 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 diff --git a/packages/js-sdk/tests/connectionConfig.test.ts b/packages/js-sdk/tests/connectionConfig.test.ts index a81de70ace..22475dfaae 100644 --- a/packages/js-sdk/tests/connectionConfig.test.ts +++ b/packages/js-sdk/tests/connectionConfig.test.ts @@ -42,6 +42,12 @@ test('api_url defaults correctly', () => { assert.equal(config.apiUrl, 'https://api.e2b.app') }) +test('retries default to zero and accept a non-negative integer', () => { + assert.equal(new ConnectionConfig().retries, 0) + 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') diff --git a/packages/js-sdk/tests/retry.test.ts b/packages/js-sdk/tests/retry.test.ts new file mode 100644 index 0000000000..908aea73f3 --- /dev/null +++ b/packages/js-sdk/tests/retry.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test, vi } from 'vitest' + +import { + parseRetryAfter, + resolveRetries, + withRateLimitRetry, +} from '../src/retry' + +describe('resolveRetries', () => { + test('defaults to zero', () => { + expect(resolveRetries()).toBe(0) + }) + + test.each([-1, 1.5, Number.NaN])('rejects %s', (retries) => { + expect(() => resolveRetries(retries)).toThrow( + 'expected a non-negative integer' + ) + }) +}) + +describe('parseRetryAfter', () => { + test.each([ + ['0', 0], + [' 12 ', 12], + ['2147483', 2_147_483], + [null, undefined], + ['', undefined], + ['-1', undefined], + ['1.5', undefined], + ['Wed, 21 Oct 2015 07:28:00 GMT', undefined], + ['2147484', undefined], + ])('parses %j as %s', (value, expected) => { + expect(parseRetryAfter(value)).toBe(expected) + }) +}) + +test('retries a buffered request and cancels the intermediate response', async () => { + const bodies: string[] = [] + const rateLimited = new Response('rate limited', { + status: 429, + headers: { 'Retry-After': '2' }, + }) + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + bodies.push(await (input as Request).text()) + return bodies.length === 1 ? rateLimited : new Response('ok') + }) as typeof fetch + const sleep = vi.fn(async () => {}) + const fetchWithRetry = withRateLimitRetry(fetchImpl, 1, 10_000, { + monotonic: () => 0, + sleep, + }) + + const response = await fetchWithRetry('https://api.e2b.test/resource', { + method: 'POST', + body: 'request body', + }) + + expect(await response.text()).toBe('ok') + expect(bodies).toEqual(['request body', 'request body']) + expect(sleep).toHaveBeenCalledWith(2_000, expect.any(AbortSignal)) + expect(rateLimited.bodyUsed).toBe(true) +}) + +test('returns the final 429 after exhausting retries', async () => { + const fetchImpl = vi.fn(async () => { + return new Response(null, { + status: 429, + headers: { 'Retry-After': '0' }, + }) + }) as typeof fetch + const fetchWithRetry = withRateLimitRetry(fetchImpl, 2, 10_000, { + monotonic: () => 0, + sleep: async () => {}, + }) + + const response = await fetchWithRetry('https://api.e2b.test/resource') + + expect(response.status).toBe(429) + expect(fetchImpl).toHaveBeenCalledTimes(3) +}) + +test('propagates 429 when Retry-After exceeds the request timeout', async () => { + const fetchImpl = vi.fn(async () => { + return new Response(null, { + status: 429, + headers: { 'Retry-After': '2' }, + }) + }) as typeof fetch + const sleep = vi.fn(async () => {}) + const fetchWithRetry = withRateLimitRetry(fetchImpl, 1, 1_000, { + monotonic: () => 0, + sleep, + }) + + const response = await fetchWithRetry('https://api.e2b.test/resource') + + expect(response.status).toBe(429) + expect(fetchImpl).toHaveBeenCalledOnce() + expect(sleep).not.toHaveBeenCalled() +}) + +test('propagates 429 without Retry-After as is', async () => { + const rateLimited = new Response('rate limited', { status: 429 }) + const fetchImpl = vi.fn(async () => rateLimited) as typeof fetch + const sleep = vi.fn(async () => {}) + const fetchWithRetry = withRateLimitRetry(fetchImpl, 2, 10_000, { + monotonic: () => 0, + sleep, + }) + + const response = await fetchWithRetry('https://api.e2b.test/resource') + + expect(response).toBe(rateLimited) + expect(response.bodyUsed).toBe(false) + expect(fetchImpl).toHaveBeenCalledOnce() + expect(sleep).not.toHaveBeenCalled() +}) + +test('aborting during Retry-After sleep rejects promptly', async () => { + const rateLimited = new Response('rate limited', { + status: 429, + headers: { 'Retry-After': '1' }, + }) + const fetchImpl = vi.fn(async () => rateLimited) as typeof fetch + const fetchWithRetry = withRateLimitRetry(fetchImpl, 1, 10_000) + const controller = new AbortController() + const reason = new Error('cancelled') + + const response = fetchWithRetry('https://api.e2b.test/resource', { + signal: controller.signal, + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + controller.abort(reason) + + await expect(response).rejects.toBe(reason) + expect(rateLimited.bodyUsed).toBe(true) + expect(fetchImpl).toHaveBeenCalledOnce() +}) + +test('disabled retries pass a streaming request through unchanged', async () => { + const fetchImpl = vi.fn(async () => new Response('ok')) as typeof fetch + const fetchWithRetry = withRateLimitRetry(fetchImpl, 0, 10_000) + const body = new ReadableStream() + const init = { + method: 'POST', + body, + duplex: 'half' as const, + } + + await fetchWithRetry('https://api.e2b.test/resource', init) + + expect(fetchImpl).toHaveBeenCalledWith('https://api.e2b.test/resource', init) + expect(body.locked).toBe(false) +}) diff --git a/packages/js-sdk/tests/sandbox/configPropagation.test.ts b/packages/js-sdk/tests/sandbox/configPropagation.test.ts index eef1314bb9..0b1edb7196 100644 --- a/packages/js-sdk/tests/sandbox/configPropagation.test.ts +++ b/packages/js-sdk/tests/sandbox/configPropagation.test.ts @@ -10,6 +10,7 @@ const baseConfig = { apiKey: TEST_API_KEY, domain: 'base.e2b.dev', requestTimeoutMs: 1111, + retries: 2, debug: false, apiHeaders: { 'X-Test': 'base' }, } @@ -49,6 +50,7 @@ describe('Sandbox API config propagation', () => { assert.equal(opts?.apiKey, baseConfig.apiKey) assert.equal(opts?.domain, baseConfig.domain) assert.equal(opts?.requestTimeoutMs, baseConfig.requestTimeoutMs) + assert.equal(opts?.retries, baseConfig.retries) assert.equal(opts?.debug, baseConfig.debug) assert.equal(opts?.headers?.['X-Test'], baseConfig.apiHeaders['X-Test']) }) diff --git a/packages/js-sdk/tests/volume/file.test.ts b/packages/js-sdk/tests/volume/file.test.ts index 3b350f5f7e..ad224d8589 100644 --- a/packages/js-sdk/tests/volume/file.test.ts +++ b/packages/js-sdk/tests/volume/file.test.ts @@ -1,8 +1,14 @@ import { afterAll, beforeAll, beforeEach, describe, expect } from 'vitest' import { setupServer } from 'msw/node' - -import { VolumeError, VolumeFileType, VolumePathNotFoundError } from '../../src' -import { volumeTest } from '../setup' +import { http, HttpResponse } from 'msw' + +import { + RateLimitError, + VolumeError, + VolumeFileType, + VolumePathNotFoundError, +} from '../../src' +import { apiUrl, volumeTest } from '../setup' import { createMockVolumeApi } from './mockVolumeContent' const server = setupServer() @@ -15,6 +21,69 @@ beforeEach(() => server.resetHandlers(...createMockVolumeApi())) describe('Volume File Operations', () => { describe('writeFile and readFile', () => { + volumeTest( + 'retries a buffered upload after rate limiting', + async ({ volume }) => { + let attempts = 0 + server.use( + http.put(apiUrl('/volumecontent/:volumeID/file'), () => { + attempts++ + if (attempts === 1) { + return new HttpResponse(null, { + status: 429, + headers: { 'Retry-After': '0' }, + }) + } + const timestamp = new Date().toISOString() + return HttpResponse.json({ + name: 'retry.txt', + type: VolumeFileType.FILE, + path: '/retry.txt', + size: 5, + mode: 0o644, + uid: 0, + gid: 0, + atime: timestamp, + mtime: timestamp, + ctime: timestamp, + }) + }) + ) + + const stat = await volume.writeFile('/retry.txt', 'retry', { + retries: 1, + }) + + expect(stat.path).toBe('/retry.txt') + expect(attempts).toBe(2) + } + ) + + volumeTest( + 'does not retry a streamed upload after rate limiting', + async ({ volume }) => { + let attempts = 0 + server.use( + http.put(apiUrl('/volumecontent/:volumeID/file'), () => { + attempts++ + return HttpResponse.json( + { message: 'rate limited' }, + { + status: 429, + headers: { 'Retry-After': '0' }, + } + ) + }) + ) + const stream = new Blob(['streamed']).stream() + + await expect( + volume.writeFile('/stream.txt', stream, { retries: 1 }) + ).rejects.toBeInstanceOf(RateLimitError) + expect(attempts).toBe(1) + } + ) + volumeTest('should write and read a text file', async ({ volume }) => { const path = '/test.txt' const content = 'Hello, World!' diff --git a/packages/python-sdk/e2b/_retry.py b/packages/python-sdk/e2b/_retry.py new file mode 100644 index 0000000000..64cfbcb574 --- /dev/null +++ b/packages/python-sdk/e2b/_retry.py @@ -0,0 +1,191 @@ +import asyncio +import time +from typing import Awaitable, Callable, Optional + +import httpx + +MAX_RETRY_AFTER_SECONDS = 2_147_483_647 +REPLAYABLE_BODY_EXTENSION = "e2b_replayable_body" + + +def resolve_max_retries(retries: Optional[int]) -> int: + if retries is None: + return 0 + if isinstance(retries, bool) or not isinstance(retries, int) or retries < 0: + raise ValueError( + f"Invalid retries={retries!r}: expected a non-negative integer." + ) + return retries + + +def parse_retry_after(value: Optional[str]) -> Optional[int]: + if not value: + return None + + value = value.strip() + if not value.isascii() or not value.isdecimal() or len(value) > 10: + return None + delay = int(value) + return delay if delay <= MAX_RETRY_AFTER_SECONDS else None + + +def _copy_request( + request: httpx.Request, remaining_timeout: Optional[float] = None +) -> httpx.Request: + extensions = dict(request.extensions) + timeout = extensions.get("timeout") + if remaining_timeout is not None and isinstance(timeout, dict): + adjusted_timeout = { + key: remaining_timeout if value is None else min(value, remaining_timeout) + for key, value in timeout.items() + } + operation_phases = [ + key for key in ("read", "write") if adjusted_timeout.get(key) is not None + ] + operation_timeout = sum( + max(adjusted_timeout[key], 0.0) for key in operation_phases + ) + if operation_timeout > remaining_timeout: + scale = remaining_timeout / operation_timeout + for key in operation_phases: + adjusted_timeout[key] = max(adjusted_timeout[key], 0.0) * scale + extensions["timeout"] = adjusted_timeout + + return httpx.Request( + request.method, + request.url, + headers=request.headers, + content=request.content, + extensions=extensions, + ) + + +def _request_deadline( + request: httpx.Request, monotonic: Callable[[], float] +) -> Optional[float]: + timeout = request.extensions.get("timeout") + if not isinstance(timeout, dict): + return None + + values = [value for value in timeout.values() if value is not None] + # A monotonic clock cannot jump when the system wall clock is adjusted, + # keeping elapsed timeout calculations stable across retries. + return monotonic() + min(values) if values else None + + +class RateLimitTransport(httpx.BaseTransport): + """Retry replayable requests after a 429 carrying ``Retry-After``.""" + + def __init__( + self, + transport: httpx.BaseTransport, + retries: int, + sleep: Callable[[float], None] = time.sleep, + monotonic: Callable[[], float] = time.monotonic, + ): + self.transport = transport + self.retries = retries + self._sleep = sleep + self._monotonic = monotonic + + def handle_request(self, request: httpx.Request) -> httpx.Response: + # Iterators, files, and other streaming bodies may be consumed by the + # first attempt and cannot be safely replayed without buffering them. + replayable = ( + isinstance(request.stream, httpx.ByteStream) + or request.extensions.get(REPLAYABLE_BODY_EXTENSION) is True + ) + if self.retries == 0 or not replayable: + return self.transport.handle_request(request) + + request.read() + deadline = _request_deadline(request, self._monotonic) + for attempt in range(self.retries + 1): + remaining_timeout = None + if attempt > 0 and deadline is not None: + remaining_timeout = deadline - self._monotonic() + if remaining_timeout <= 0: + raise httpx.TimeoutException( + "Request timeout exhausted while retrying", request=request + ) + response = self.transport.handle_request( + _copy_request(request, remaining_timeout) + ) + retry_after = parse_retry_after(response.headers.get("Retry-After")) + if ( + response.status_code != 429 + or retry_after is None + or attempt == self.retries + or ( + deadline is not None and self._monotonic() + retry_after >= deadline + ) + ): + return response + + response.close() + self._sleep(retry_after) + + raise AssertionError("unreachable") + + def close(self) -> None: + # The underlying transport comes from a process-wide cache. + pass + + +class AsyncRateLimitTransport(httpx.AsyncBaseTransport): + """Async counterpart of :class:`RateLimitTransport`.""" + + def __init__( + self, + transport: httpx.AsyncBaseTransport, + retries: int, + sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + monotonic: Callable[[], float] = time.monotonic, + ): + self.transport = transport + self.retries = retries + self._sleep = sleep + self._monotonic = monotonic + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + # Iterators, files, and other streaming bodies may be consumed by the + # first attempt and cannot be safely replayed without buffering them. + replayable = ( + isinstance(request.stream, httpx.ByteStream) + or request.extensions.get(REPLAYABLE_BODY_EXTENSION) is True + ) + if self.retries == 0 or not replayable: + return await self.transport.handle_async_request(request) + + await request.aread() + deadline = _request_deadline(request, self._monotonic) + for attempt in range(self.retries + 1): + remaining_timeout = None + if attempt > 0 and deadline is not None: + remaining_timeout = deadline - self._monotonic() + if remaining_timeout <= 0: + raise httpx.TimeoutException( + "Request timeout exhausted while retrying", request=request + ) + response = await self.transport.handle_async_request( + _copy_request(request, remaining_timeout) + ) + retry_after = parse_retry_after(response.headers.get("Retry-After")) + if ( + response.status_code != 429 + or retry_after is None + or attempt == self.retries + or ( + deadline is not None and self._monotonic() + retry_after >= deadline + ) + ): + return response + + await response.aclose() + await self._sleep(retry_after) + + raise AssertionError("unreachable") + + async def aclose(self) -> None: + # The underlying transport comes from a process-wide cache. + pass diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index 1bf3542ea2..efc95d2eed 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -7,6 +7,7 @@ from pyqwest.httpx import AsyncPyqwestTransport from pyqwest.middleware.retry import RetryMode, RetryTransport +from e2b._retry import AsyncRateLimitTransport from e2b.api import ( AsyncApiClient, ProxyConfig, @@ -152,13 +153,18 @@ def get_transport( *, for_streaming: bool = False, pool_shard: int = 0, -) -> AsyncPyqwestTransport: - """The shared httpx transport factory for the control-plane REST API and +) -> httpx.AsyncBaseTransport: + """The httpx transport factory for the control-plane REST API and envd HTTP API (file transfers, health checks). Generic callers use shard zero; :func:`get_envd_transport` supplies a sandbox-specific shard. For TLS connections ALPN negotiates the HTTP version (HTTP/2 against the E2B API), like the http2-enabled httpx transport this replaced. + The pyqwest adapter and its connection pool are shared. Rate-limit policy + is applied in a per-call wrapper so clients with different retry settings + can still reuse the same pool; the wrapper delegates directly when retries + are disabled. + ``http2=False`` returns a separate transport (its own pool) pinned to HTTP/1.1. That matters for a server that reacts to a client going away: HTTP/2 multiplexes requests over one connection, so abandoning a request @@ -174,17 +180,18 @@ def get_transport( downloads take it, and they get their own pool (see :func:`get_pyqwest_transport`). """ - return get_httpx_transport( + transport = get_httpx_transport( proxy_to_config(config.proxy), READ_TIMEOUT if for_streaming else None, http2, pool_shard, ) + return AsyncRateLimitTransport(transport, config.retries) def get_envd_transport( config: ConnectionConfig, http2: bool = True, *, for_streaming: bool = False -) -> AsyncPyqwestTransport: +) -> httpx.AsyncBaseTransport: """The envd HTTP API's transport, sharded by sandbox ID. Envd RPC and non-streaming HTTP traffic for one sandbox resolve the same diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index 4d82a93cd9..16a6e36010 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -7,6 +7,7 @@ from pyqwest.httpx import PyqwestTransport from pyqwest.middleware.retry import RetryMode, SyncRetryTransport +from e2b._retry import RateLimitTransport from e2b.api import ( ApiClient, ProxyConfig, @@ -151,13 +152,18 @@ def get_transport( *, for_streaming: bool = False, pool_shard: int = 0, -) -> PyqwestTransport: - """The shared httpx transport factory for the control-plane REST API and +) -> httpx.BaseTransport: + """The httpx transport factory for the control-plane REST API and envd HTTP API (file transfers, health checks). Generic callers use shard zero; :func:`get_envd_transport` supplies a sandbox-specific shard. For TLS connections ALPN negotiates the HTTP version (HTTP/2 against the E2B API), like the http2-enabled httpx transport this replaced. + The pyqwest adapter and its connection pool are shared. Rate-limit policy + is applied in a per-call wrapper so clients with different retry settings + can still reuse the same pool; the wrapper delegates directly when retries + are disabled. + ``http2=False`` returns a separate transport (its own pool) pinned to HTTP/1.1. That matters for a server that reacts to a client going away: HTTP/2 multiplexes requests over one connection, so abandoning a request @@ -173,17 +179,18 @@ def get_transport( downloads take it, and they get their own pool (see :func:`get_pyqwest_transport`). """ - return get_httpx_transport( + transport = get_httpx_transport( proxy_to_config(config.proxy), READ_TIMEOUT if for_streaming else None, http2, pool_shard, ) + return RateLimitTransport(transport, config.retries) def get_envd_transport( config: ConnectionConfig, http2: bool = True, *, for_streaming: bool = False -) -> PyqwestTransport: +) -> httpx.BaseTransport: """The envd HTTP API's transport, sharded by sandbox ID. Envd RPC and non-streaming HTTP traffic for one sandbox resolve the same diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 2ffa7160bf..70f3aa0a59 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -7,6 +7,7 @@ import httpx from typing_extensions import Unpack +from e2b._retry import resolve_max_retries from e2b.api.metadata import package_version from e2b.sandbox_domains import is_supported_sandbox_domain @@ -48,6 +49,11 @@ class ApiParams(TypedDict, total=False): request_timeout: Optional[float] """Timeout for the request in **seconds**, defaults to 60 seconds.""" + retries: Optional[int] + """Number of HTTP retries after a 429 with ``Retry-After``. + A retry is skipped when its wait would exhaust the request timeout. + Defaults to no retries.""" + headers: Optional[Dict[str, str]] """Additional headers to send with the request. Deprecated, use api_headers instead.""" @@ -237,6 +243,7 @@ def __init__( extra_sandbox_headers: Optional[Dict[str, str]] = None, proxy: Optional[ProxyTypes] = None, logger: Optional[logging.Logger] = None, + retries: Optional[int] = None, ): self.logger = logger self.domain = domain or ConnectionConfig._domain() @@ -252,6 +259,7 @@ def __init__( self.__extra_sandbox_headers = extra_sandbox_headers or {} self.proxy = proxy + self.retries = resolve_max_retries(retries) self.request_timeout = ConnectionConfig._get_request_timeout( REQUEST_TIMEOUT, @@ -345,6 +353,7 @@ def get_api_params( debug = opts.get("debug") proxy = opts.get("proxy") sandbox_url = opts.get("sandbox_url") + retries = opts.get("retries") req_headers = self.headers.copy() if headers is not None: @@ -384,6 +393,7 @@ def get_api_params( else cast(Optional[str], self._sandbox_url) ), logger=self.logger, + retries=retries if retries is not None else self.retries, ) ) diff --git a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py index 725662fad0..db5e320539 100644 --- a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py @@ -7,6 +7,7 @@ from connectrpc.errors import ConnectError from packaging.version import Version +from e2b._retry import REPLAYABLE_BODY_EXTENSION from e2b.api.client_async import get_envd_api from e2b.connection_config import ( KEEPALIVE_PING_HEADER, @@ -448,6 +449,7 @@ async def _upload_file(file): if len(httpx_files) == 0: return [] + body_is_streamed = multipart_body_is_streamed(files) try: r = await self._envd_api.post( ENVD_API_FILES_ROUTE, @@ -458,9 +460,8 @@ async def _upload_file(file): # forwards binary `IOBase` entries in chunks, while text # file-like data was buffered by `_to_httpx_file` (httpx # rejects text-mode objects in multipart). - timeout=( - None if multipart_body_is_streamed(files) else upload_timeout - ), + timeout=(None if body_is_streamed else upload_timeout), + extensions={REPLAYABLE_BODY_EXTENSION: not body_is_streamed}, ) except httpx.RemoteProtocolError as e: raise await ahandle_envd_api_transport_exception_with_health( diff --git a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py index d3a2ba6463..6c67d6cce6 100644 --- a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py @@ -5,6 +5,7 @@ from connectrpc.errors import ConnectError from packaging.version import Version +from e2b._retry import REPLAYABLE_BODY_EXTENSION from e2b.api.client_sync import get_envd_api from e2b.connection_config import ( KEEPALIVE_PING_HEADER, @@ -412,6 +413,7 @@ def write_files( if len(httpx_files) == 0: return [] + body_is_streamed = multipart_body_is_streamed(files) try: r = self._envd_api.post( ENVD_API_FILES_ROUTE, @@ -422,9 +424,8 @@ def write_files( # forwards binary `IOBase` entries in chunks, while text # file-like data was buffered by `_to_httpx_file` (httpx # rejects text-mode objects in multipart). - timeout=( - None if multipart_body_is_streamed(files) else upload_timeout - ), + timeout=(None if body_is_streamed else upload_timeout), + extensions={REPLAYABLE_BODY_EXTENSION: not body_is_streamed}, ) except httpx.RemoteProtocolError as e: raise handle_envd_api_transport_exception_with_health(e, self._envd_api) diff --git a/packages/python-sdk/e2b/volume/client_async/__init__.py b/packages/python-sdk/e2b/volume/client_async/__init__.py index 3aed1272a5..ccc7fbc5b1 100644 --- a/packages/python-sdk/e2b/volume/client_async/__init__.py +++ b/packages/python-sdk/e2b/volume/client_async/__init__.py @@ -1,6 +1,6 @@ import httpx -from pyqwest.httpx import AsyncPyqwestTransport +from e2b._retry import AsyncRateLimitTransport from e2b.api import ( make_async_logging_event_hooks, proxy_to_config, @@ -26,7 +26,7 @@ def get_streaming_api_client( def _api_client( - config: VolumeConnectionConfig, transport: AsyncPyqwestTransport, **kwargs + config: VolumeConnectionConfig, transport: httpx.AsyncBaseTransport, **kwargs ) -> AsyncVolumeApiClient: if config.access_token is None: raise AuthenticationException( @@ -61,7 +61,7 @@ def _api_client( ) -def get_transport(config: VolumeConnectionConfig) -> AsyncPyqwestTransport: +def get_transport(config: VolumeConnectionConfig) -> httpx.AsyncBaseTransport: """The shared pyqwest-backed httpx transport for volume content API calls — the same pool the control-plane REST API and the envd HTTP API draw from (see :func:`e2b.api.client_async.get_pyqwest_transport`); reqwest pools per @@ -73,12 +73,13 @@ def get_transport(config: VolumeConnectionConfig) -> AsyncPyqwestTransport: their whole-request deadlines instead). Streamed downloads, which do need an idle bound, use :func:`get_streaming_transport`. """ - return get_httpx_transport(proxy_to_config(config.proxy)) + transport = get_httpx_transport(proxy_to_config(config.proxy)) + return AsyncRateLimitTransport(transport, config.retries) def get_streaming_transport( config: VolumeConnectionConfig, -) -> AsyncPyqwestTransport: +) -> httpx.AsyncBaseTransport: """The transport for streamed downloads, carrying ``READ_TIMEOUT`` as the idle bound on every read: it resets after each successful read, so it caps how long a streamed download may stall without limiting total transfer @@ -87,4 +88,5 @@ def get_streaming_transport( their own, shared with the sandbox filesystem's streaming transport whenever the two bounds agree. """ - return get_httpx_transport(proxy_to_config(config.proxy), READ_TIMEOUT) + transport = get_httpx_transport(proxy_to_config(config.proxy), READ_TIMEOUT) + return AsyncRateLimitTransport(transport, config.retries) diff --git a/packages/python-sdk/e2b/volume/client_sync/__init__.py b/packages/python-sdk/e2b/volume/client_sync/__init__.py index 57344abbd2..ddb31652d7 100644 --- a/packages/python-sdk/e2b/volume/client_sync/__init__.py +++ b/packages/python-sdk/e2b/volume/client_sync/__init__.py @@ -1,6 +1,6 @@ import httpx -from pyqwest.httpx import PyqwestTransport +from e2b._retry import RateLimitTransport from e2b.api import ( make_logging_event_hooks, proxy_to_config, @@ -26,7 +26,7 @@ def get_streaming_api_client( def _api_client( - config: VolumeConnectionConfig, transport: PyqwestTransport, **kwargs + config: VolumeConnectionConfig, transport: httpx.BaseTransport, **kwargs ) -> VolumeApiClient: if config.access_token is None: raise AuthenticationException( @@ -61,7 +61,7 @@ def _api_client( ) -def get_transport(config: VolumeConnectionConfig) -> PyqwestTransport: +def get_transport(config: VolumeConnectionConfig) -> httpx.BaseTransport: """The shared pyqwest-backed httpx transport for volume content API calls — the same pool the control-plane REST API and the envd HTTP API draw from (see :func:`e2b.api.client_sync.get_pyqwest_transport`); reqwest pools per @@ -73,10 +73,11 @@ def get_transport(config: VolumeConnectionConfig) -> PyqwestTransport: their whole-request deadlines instead). Streamed downloads, which do need an idle bound, use :func:`get_streaming_transport`. """ - return get_httpx_transport(proxy_to_config(config.proxy)) + transport = get_httpx_transport(proxy_to_config(config.proxy)) + return RateLimitTransport(transport, config.retries) -def get_streaming_transport(config: VolumeConnectionConfig) -> PyqwestTransport: +def get_streaming_transport(config: VolumeConnectionConfig) -> httpx.BaseTransport: """The transport for streamed downloads, carrying ``READ_TIMEOUT`` as the idle bound on every read: it resets after each successful read, so it caps how long a streamed download may stall without limiting total transfer @@ -85,4 +86,5 @@ def get_streaming_transport(config: VolumeConnectionConfig) -> PyqwestTransport: their own, shared with the sandbox filesystem's streaming transport whenever the two bounds agree. """ - return get_httpx_transport(proxy_to_config(config.proxy), READ_TIMEOUT) + transport = get_httpx_transport(proxy_to_config(config.proxy), READ_TIMEOUT) + return RateLimitTransport(transport, config.retries) diff --git a/packages/python-sdk/e2b/volume/connection_config.py b/packages/python-sdk/e2b/volume/connection_config.py index 905badebee..adfa1bd5f4 100644 --- a/packages/python-sdk/e2b/volume/connection_config.py +++ b/packages/python-sdk/e2b/volume/connection_config.py @@ -5,6 +5,7 @@ from typing_extensions import Unpack +from e2b._retry import resolve_max_retries from e2b.api.metadata import package_version from e2b.connection_config import ProxyTypes @@ -41,6 +42,10 @@ class VolumeApiParams(TypedDict, total=False): request_timeout: Optional[float] """Timeout for the request in **seconds**, defaults to 60 seconds.""" + retries: Optional[int] + """Number of HTTP retries after a 429 with ``Retry-After``. + Defaults to no retries.""" + headers: Optional[Dict[str, str]] """Additional headers to send with the request.""" @@ -98,6 +103,7 @@ def __init__( headers: Optional[Dict[str, str]] = None, proxy: Optional[ProxyTypes] = None, logger: Optional[logging.Logger] = None, + retries: Optional[int] = None, ): self.logger = logger self.domain = domain or self._domain() @@ -111,6 +117,7 @@ def __init__( self.access_token = token self.token = self.access_token self.proxy = proxy + self.retries = resolve_max_retries(retries) self.headers = dict(headers) if headers else {} self.headers["User-Agent"] = f"e2b-python-sdk/{package_version}" @@ -137,6 +144,7 @@ def get_api_params( api_url = opts.get("api_url") proxy = opts.get("proxy") logger = opts.get("logger") + retries = opts.get("retries") req_headers = self.headers.copy() if headers is not None: @@ -152,5 +160,6 @@ def get_api_params( headers=req_headers, proxy=proxy if proxy is not None else self.proxy, logger=logger if logger is not None else self.logger, + retries=retries if retries is not None else self.retries, ) ) diff --git a/packages/python-sdk/e2b/volume/volume_async.py b/packages/python-sdk/e2b/volume/volume_async.py index 192234f40e..209d73b29a 100644 --- a/packages/python-sdk/e2b/volume/volume_async.py +++ b/packages/python-sdk/e2b/volume/volume_async.py @@ -78,6 +78,7 @@ def __init__( domain: Optional[str] = None, debug: Optional[bool] = None, proxy: Optional[ProxyTypes] = None, + retries: Optional[int] = None, ): self._volume_id = volume_id self._name = name @@ -85,6 +86,7 @@ def __init__( self._domain = domain self._debug = debug self._proxy = proxy + self._retries = retries @property def volume_id(self) -> str: @@ -110,6 +112,11 @@ def _get_volume_config( headers=opts.get("headers"), logger=opts.get("logger"), proxy=opts.get("proxy") if opts.get("proxy") is not None else self._proxy, + retries=( + opts.get("retries") + if opts.get("retries") is not None + else self._retries + ), ) @classmethod @@ -150,6 +157,7 @@ async def create(cls, name: str, **opts: Unpack[ApiParams]) -> Self: domain=domain or config.domain, debug=config.debug, proxy=config.proxy, + retries=config.retries, ) return vol @@ -171,6 +179,7 @@ async def connect(cls, volume_id: str, **opts: Unpack[ApiParams]) -> Self: domain=info.domain or config.domain, debug=config.debug, proxy=config.proxy, + retries=config.retries, ) @classmethod diff --git a/packages/python-sdk/e2b/volume/volume_sync.py b/packages/python-sdk/e2b/volume/volume_sync.py index eee5d1b1db..c8f2e592b5 100644 --- a/packages/python-sdk/e2b/volume/volume_sync.py +++ b/packages/python-sdk/e2b/volume/volume_sync.py @@ -77,6 +77,7 @@ def __init__( domain: Optional[str] = None, debug: Optional[bool] = None, proxy: Optional[ProxyTypes] = None, + retries: Optional[int] = None, ): self._volume_id = volume_id self._name = name @@ -84,6 +85,7 @@ def __init__( self._domain = domain self._debug = debug self._proxy = proxy + self._retries = retries @property def volume_id(self) -> str: @@ -109,6 +111,11 @@ def _get_volume_config( headers=opts.get("headers"), logger=opts.get("logger"), proxy=opts.get("proxy") if opts.get("proxy") is not None else self._proxy, + retries=( + opts.get("retries") + if opts.get("retries") is not None + else self._retries + ), ) @classmethod @@ -149,6 +156,7 @@ def create(cls, name: str, **opts: Unpack[ApiParams]) -> Self: domain=domain or config.domain, debug=config.debug, proxy=config.proxy, + retries=config.retries, ) return vol @@ -170,6 +178,7 @@ def connect(cls, volume_id: str, **opts: Unpack[ApiParams]) -> Self: domain=info.domain or config.domain, debug=config.debug, proxy=config.proxy, + retries=config.retries, ) @classmethod diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index da7a29fe0a..3e1076fd8a 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -35,6 +35,19 @@ from e2b.api.client_sync import get_pyqwest_transport as get_sync_pyqwest_transport from e2b.api.client_sync import get_transport as get_sync_transport from e2b.connection_config import READ_TIMEOUT, ConnectionConfig +from e2b._retry import AsyncRateLimitTransport, RateLimitTransport + + +def unwrap_rate_limit_transport(transport: httpx.BaseTransport) -> httpx.BaseTransport: + assert isinstance(transport, RateLimitTransport) + return transport.transport + + +def unwrap_async_rate_limit_transport( + transport: httpx.AsyncBaseTransport, +) -> httpx.AsyncBaseTransport: + assert isinstance(transport, AsyncRateLimitTransport) + return transport.transport def run_in_worker_thread(fn): @@ -78,14 +91,45 @@ def test_sync_api_client_proxy_uses_explicit_transport(test_api_key): try: assert "proxy" not in api_client._httpx_args - assert httpx_client._transport is get_sync_transport(config) - assert isinstance(httpx_client._transport, PyqwestTransport) + assert unwrap_rate_limit_transport( + httpx_client._transport + ) is unwrap_rate_limit_transport(get_sync_transport(config)) + assert isinstance( + unwrap_rate_limit_transport(httpx_client._transport), PyqwestTransport + ) assert httpx_client._mounts == {} finally: httpx_client.close() reset_transport_caches() +def test_sync_retry_policy_wraps_but_does_not_split_cached_transport(test_api_key): + reset_transport_caches() + default_client = get_sync_api_client(ConnectionConfig(api_key=test_api_key)) + retrying_client = get_sync_api_client( + ConnectionConfig(api_key=test_api_key, retries=2) + ) + default_httpx = default_client.get_httpx_client() + retrying_httpx = retrying_client.get_httpx_client() + retrying_transport = get_sync_transport( + ConnectionConfig(api_key=test_api_key, retries=1) + ) + + try: + assert isinstance(default_httpx._transport, RateLimitTransport) + assert default_httpx._transport.retries == 0 + assert isinstance(retrying_httpx._transport, RateLimitTransport) + assert retrying_httpx._transport.retries == 2 + assert retrying_httpx._transport.transport is default_httpx._transport.transport + assert isinstance(retrying_transport, RateLimitTransport) + assert retrying_transport.retries == 1 + assert retrying_transport.transport is default_httpx._transport.transport + finally: + retrying_httpx.close() + default_httpx.close() + reset_transport_caches() + + def test_sync_get_transport_keyed_by_proxy(test_api_key): reset_transport_caches() proxied_config = ConnectionConfig( @@ -99,16 +143,28 @@ def test_sync_get_transport_keyed_by_proxy(test_api_key): ) try: - proxied_transport = get_sync_transport(proxied_config) - direct_transport = get_sync_transport(direct_config) - other_proxy_transport = get_sync_transport(other_proxy_config) + proxied_transport = unwrap_rate_limit_transport( + get_sync_transport(proxied_config) + ) + direct_transport = unwrap_rate_limit_transport( + get_sync_transport(direct_config) + ) + other_proxy_transport = unwrap_rate_limit_transport( + get_sync_transport(other_proxy_config) + ) assert proxied_transport is not direct_transport assert proxied_transport is not other_proxy_transport assert direct_transport is not other_proxy_transport # The same proxy still reuses the cached instance. - assert get_sync_transport(proxied_config) is proxied_transport - assert get_sync_transport(direct_config) is direct_transport + assert ( + unwrap_rate_limit_transport(get_sync_transport(proxied_config)) + is proxied_transport + ) + assert ( + unwrap_rate_limit_transport(get_sync_transport(direct_config)) + is direct_transport + ) finally: reset_transport_caches() @@ -124,10 +180,12 @@ def test_sync_transports_keyed_by_http_version(test_api_key): ) try: - negotiated = get_sync_transport(config) - http1 = get_sync_transport(config, http2=False) - envd_negotiated = get_sync_envd_transport(config) - envd_http1 = get_sync_envd_transport(config, http2=False) + negotiated = unwrap_rate_limit_transport(get_sync_transport(config)) + http1 = unwrap_rate_limit_transport(get_sync_transport(config, http2=False)) + envd_negotiated = unwrap_rate_limit_transport(get_sync_envd_transport(config)) + envd_http1 = unwrap_rate_limit_transport( + get_sync_envd_transport(config, http2=False) + ) assert http1 is not negotiated assert envd_http1 is not envd_negotiated @@ -137,15 +195,25 @@ def test_sync_transports_keyed_by_http_version(test_api_key): assert envd_http1 is http1 # Each version still has one pool per proxy, and repeat calls with the # same arguments reuse it. - assert get_sync_transport(proxied_config, http2=False) not in ( + assert unwrap_rate_limit_transport( + get_sync_transport(proxied_config, http2=False) + ) not in ( http1, negotiated, ) - assert get_sync_transport(config, http2=False) is http1 - assert get_sync_transport(config) is negotiated - assert get_sync_envd_transport(config, http2=False) is envd_http1 assert ( - get_sync_envd_transport(config, http2=False, for_streaming=True) + unwrap_rate_limit_transport(get_sync_transport(config, http2=False)) + is http1 + ) + assert unwrap_rate_limit_transport(get_sync_transport(config)) is negotiated + assert ( + unwrap_rate_limit_transport(get_sync_envd_transport(config, http2=False)) + is envd_http1 + ) + assert ( + unwrap_rate_limit_transport( + get_sync_envd_transport(config, http2=False, for_streaming=True) + ) is not envd_http1 ) finally: @@ -164,13 +232,17 @@ def test_sync_envd_transports_are_consistently_sharded_by_sandbox( try: assert envd_pool_shard(first) == envd_pool_shard(same_shard) assert envd_pool_shard(first) != envd_pool_shard(different_shard) - assert get_sync_envd_transport(first) is get_sync_envd_transport(same_shard) - assert get_sync_envd_transport(first) is not get_sync_envd_transport( - different_shard - ) + assert unwrap_rate_limit_transport( + get_sync_envd_transport(first) + ) is unwrap_rate_limit_transport(get_sync_envd_transport(same_shard)) + assert unwrap_rate_limit_transport( + get_sync_envd_transport(first) + ) is not unwrap_rate_limit_transport(get_sync_envd_transport(different_shard)) # Generic API traffic remains on shard zero rather than multiplying # control-plane connections for every envd shard. - assert get_sync_envd_transport(first) is not get_sync_transport(first) + assert unwrap_rate_limit_transport( + get_sync_envd_transport(first) + ) is not unwrap_rate_limit_transport(get_sync_transport(first)) finally: reset_transport_caches() @@ -273,10 +345,19 @@ def test_sync_generic_transport_separates_streaming_read_timeout(test_api_key): api_transport = get_sync_transport(config) streaming_transport = get_sync_transport(config, for_streaming=True) - assert isinstance(api_transport, PyqwestTransport) - assert api_transport is get_sync_transport(config, for_streaming=False) - assert streaming_transport is not api_transport - assert get_sync_transport(config, for_streaming=True) is streaming_transport + assert isinstance(api_transport, RateLimitTransport) + assert isinstance(api_transport.transport, PyqwestTransport) + assert unwrap_rate_limit_transport( + api_transport + ) is unwrap_rate_limit_transport( + get_sync_transport(config, for_streaming=False) + ) + assert unwrap_rate_limit_transport( + streaming_transport + ) is not unwrap_rate_limit_transport(api_transport) + assert unwrap_rate_limit_transport( + get_sync_transport(config, for_streaming=True) + ) is unwrap_rate_limit_transport(streaming_transport) finally: reset_transport_caches() @@ -290,8 +371,12 @@ def test_sync_envd_api_client_wiring(test_api_key): try: assert client.base_url == "https://sandbox.e2b.app" - assert client._transport is get_sync_transport(config) - assert streaming._transport is get_sync_transport(config, for_streaming=True) + assert unwrap_rate_limit_transport( + client._transport + ) is unwrap_rate_limit_transport(get_sync_transport(config)) + assert unwrap_rate_limit_transport( + streaming._transport + ) is unwrap_rate_limit_transport(get_sync_transport(config, for_streaming=True)) for header, value in config.sandbox_headers.items(): assert client.headers[header] == value finally: @@ -332,8 +417,13 @@ async def test_async_api_client_proxy_uses_explicit_transport(test_api_key): try: assert "proxy" not in api_client._httpx_args - assert httpx_client._transport is get_async_transport(config) - assert isinstance(httpx_client._transport, AsyncPyqwestTransport) + assert unwrap_async_rate_limit_transport( + httpx_client._transport + ) is unwrap_async_rate_limit_transport(get_async_transport(config)) + assert isinstance( + unwrap_async_rate_limit_transport(httpx_client._transport), + AsyncPyqwestTransport, + ) assert httpx_client._mounts == {} finally: await httpx_client.aclose() @@ -350,13 +440,23 @@ async def test_async_get_transport_keyed_by_proxy(test_api_key): direct_config = ConnectionConfig(api_key=test_api_key) try: - proxied_transport = get_async_transport(proxied_config) - direct_transport = get_async_transport(direct_config) + proxied_transport = unwrap_async_rate_limit_transport( + get_async_transport(proxied_config) + ) + direct_transport = unwrap_async_rate_limit_transport( + get_async_transport(direct_config) + ) assert proxied_transport is not direct_transport # The same proxy still reuses the cached instance. - assert get_async_transport(proxied_config) is proxied_transport - assert get_async_transport(direct_config) is direct_transport + assert ( + unwrap_async_rate_limit_transport(get_async_transport(proxied_config)) + is proxied_transport + ) + assert ( + unwrap_async_rate_limit_transport(get_async_transport(direct_config)) + is direct_transport + ) finally: reset_transport_caches() @@ -367,10 +467,16 @@ async def test_async_transports_keyed_by_http_version(test_api_key): config = ConnectionConfig(api_key=test_api_key) try: - negotiated = get_async_transport(config) - http1 = get_async_transport(config, http2=False) - envd_negotiated = get_async_envd_transport(config) - envd_http1 = get_async_envd_transport(config, http2=False) + negotiated = unwrap_async_rate_limit_transport(get_async_transport(config)) + http1 = unwrap_async_rate_limit_transport( + get_async_transport(config, http2=False) + ) + envd_negotiated = unwrap_async_rate_limit_transport( + get_async_envd_transport(config) + ) + envd_http1 = unwrap_async_rate_limit_transport( + get_async_envd_transport(config, http2=False) + ) assert http1 is not negotiated assert envd_http1 is not envd_negotiated @@ -378,11 +484,23 @@ async def test_async_transports_keyed_by_http_version(test_api_key): # shares the generic transport for each HTTP version. assert envd_negotiated is negotiated assert envd_http1 is http1 - assert get_async_transport(config, http2=False) is http1 - assert get_async_transport(config) is negotiated - assert get_async_envd_transport(config, http2=False) is envd_http1 assert ( - get_async_envd_transport(config, http2=False, for_streaming=True) + unwrap_async_rate_limit_transport(get_async_transport(config, http2=False)) + is http1 + ) + assert ( + unwrap_async_rate_limit_transport(get_async_transport(config)) is negotiated + ) + assert ( + unwrap_async_rate_limit_transport( + get_async_envd_transport(config, http2=False) + ) + is envd_http1 + ) + assert ( + unwrap_async_rate_limit_transport( + get_async_envd_transport(config, http2=False, for_streaming=True) + ) is not envd_http1 ) finally: @@ -400,11 +518,17 @@ async def test_async_envd_transports_are_consistently_sharded_by_sandbox( different_shard = sandbox_config(test_api_key, "sbx-1") try: - assert get_async_envd_transport(first) is get_async_envd_transport(same_shard) - assert get_async_envd_transport(first) is not get_async_envd_transport( - different_shard + assert unwrap_async_rate_limit_transport( + get_async_envd_transport(first) + ) is unwrap_async_rate_limit_transport(get_async_envd_transport(same_shard)) + assert unwrap_async_rate_limit_transport( + get_async_envd_transport(first) + ) is not unwrap_async_rate_limit_transport( + get_async_envd_transport(different_shard) ) - assert get_async_envd_transport(first) is not get_async_transport(first) + assert unwrap_async_rate_limit_transport( + get_async_envd_transport(first) + ) is not unwrap_async_rate_limit_transport(get_async_transport(first)) finally: reset_transport_caches() @@ -495,10 +619,19 @@ async def test_async_generic_transport_separates_streaming_read_timeout(test_api api_transport = get_async_transport(config) streaming_transport = get_async_transport(config, for_streaming=True) - assert isinstance(api_transport, AsyncPyqwestTransport) - assert api_transport is get_async_transport(config, for_streaming=False) - assert streaming_transport is not api_transport - assert get_async_transport(config, for_streaming=True) is streaming_transport + assert isinstance(api_transport, AsyncRateLimitTransport) + assert isinstance(api_transport.transport, AsyncPyqwestTransport) + assert unwrap_async_rate_limit_transport( + api_transport + ) is unwrap_async_rate_limit_transport( + get_async_transport(config, for_streaming=False) + ) + assert unwrap_async_rate_limit_transport( + streaming_transport + ) is not unwrap_async_rate_limit_transport(api_transport) + assert unwrap_async_rate_limit_transport( + get_async_transport(config, for_streaming=True) + ) is unwrap_async_rate_limit_transport(streaming_transport) finally: reset_transport_caches() @@ -512,7 +645,9 @@ async def test_async_envd_api_client_wiring(test_api_key): try: assert client.base_url == "https://sandbox.e2b.app" - assert client._transport is get_async_transport(config) + assert unwrap_async_rate_limit_transport( + client._transport + ) is unwrap_async_rate_limit_transport(get_async_transport(config)) for header, value in config.sandbox_headers.items(): assert client.headers[header] == value finally: @@ -526,6 +661,9 @@ class _EchoHandler(BaseHTTPRequestHandler): ``/stall`` answers the head and then never sends the body, and one starting with ``/redirect`` answers 302 pointing at ``/sandboxes``.""" + rate_limit_requests = 0 + rate_limit_bodies = [] + def do_GET(self): if self.path.startswith("/slow"): time.sleep(5) @@ -535,6 +673,14 @@ def do_GET(self): self.send_header("Content-Length", "0") self.end_headers() return + if self.path.startswith("/rate-limit"): + type(self).rate_limit_requests += 1 + if type(self).rate_limit_requests == 1: + self.send_response(429) + self.send_header("Retry-After", "0") + self.send_header("Content-Length", "0") + self.end_headers() + return headers = {k.lower(): v for k, v in self.headers.items()} body = json.dumps({"path": self.path, "headers": headers}).encode() self.send_response(200) @@ -550,6 +696,14 @@ def do_GET(self): def do_POST(self): length = int(self.headers.get("Content-Length", 0)) received = self.rfile.read(length) if length else b"" + if self.path.startswith("/rate-limit"): + type(self).rate_limit_bodies.append(received) + if len(type(self).rate_limit_bodies) == 1: + self.send_response(429) + self.send_header("Retry-After", "0") + self.send_header("Content-Length", "0") + self.end_headers() + return body = json.dumps({"received": len(received)}).encode() self.send_response(200) self.send_header("Content-Type", "application/json") @@ -571,6 +725,8 @@ class _EchoServer(ThreadingHTTPServer): @pytest.fixture def echo_server(): + _EchoHandler.rate_limit_requests = 0 + _EchoHandler.rate_limit_bodies = [] server = _EchoServer(("127.0.0.1", 0), _EchoHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() @@ -638,7 +794,8 @@ def test_sync_api_client_round_trips_through_pyqwest(test_api_key, echo_server): httpx_client = api_client.get_httpx_client() try: - assert isinstance(httpx_client._transport, PyqwestTransport) + assert isinstance(httpx_client._transport, RateLimitTransport) + assert isinstance(httpx_client._transport.transport, PyqwestTransport) response = httpx_client.request("GET", "/sandboxes") assert response.status_code == 200 echoed = response.json() @@ -650,6 +807,43 @@ def test_sync_api_client_round_trips_through_pyqwest(test_api_key, echo_server): reset_transport_caches() +@pytest.mark.parametrize( + ("retries", "expected_status", "expected_requests"), [(0, 429, 1), (1, 200, 2)] +) +def test_sync_api_client_retries_rate_limits( + test_api_key, echo_server, retries, expected_status, expected_requests +): + reset_transport_caches() + config = ConnectionConfig( + api_key=test_api_key, api_url=echo_server, retries=retries + ) + httpx_client = get_sync_api_client(config).get_httpx_client() + + try: + response = httpx_client.request("GET", "/rate-limit") + assert response.status_code == expected_status + assert _EchoHandler.rate_limit_requests == expected_requests + finally: + httpx_client.close() + reset_transport_caches() + + +def test_sync_api_client_replays_buffered_body_after_rate_limit( + test_api_key, echo_server +): + reset_transport_caches() + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server, retries=1) + httpx_client = get_sync_api_client(config).get_httpx_client() + + try: + response = httpx_client.request("POST", "/rate-limit", content=b"payload") + assert response.status_code == 200 + assert _EchoHandler.rate_limit_bodies == [b"payload", b"payload"] + finally: + httpx_client.close() + reset_transport_caches() + + def test_sync_api_client_serves_concurrent_threads(test_api_key, echo_server): # The scenario the removed per-thread client caching used to guard: one # client, one shared pyqwest pool, many threads at once. @@ -699,7 +893,8 @@ async def test_async_api_client_round_trips_through_pyqwest(test_api_key, echo_s httpx_client = api_client.get_async_httpx_client() try: - assert isinstance(httpx_client._transport, AsyncPyqwestTransport) + assert isinstance(httpx_client._transport, AsyncRateLimitTransport) + assert isinstance(httpx_client._transport.transport, AsyncPyqwestTransport) response = await httpx_client.request("GET", "/sandboxes") assert response.status_code == 200 echoed = response.json() @@ -897,13 +1092,15 @@ def test_sync_closing_one_client_leaves_the_shared_pool_open(test_api_key, echo_ # must leave the others — and the pool the envd RPC stack talks to # directly — working. reset_transport_caches() - config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server, retries=1) api_httpx = get_sync_api_client(config).get_httpx_client() envd_api = get_sync_envd_api(config, echo_server) pool = get_sync_pyqwest_transport(proxy_to_config(config.proxy)) try: - assert api_httpx._transport is envd_api._transport + assert isinstance(api_httpx._transport, RateLimitTransport) + assert isinstance(envd_api._transport, RateLimitTransport) + assert api_httpx._transport.transport is envd_api._transport.transport assert api_httpx.request("GET", "/sandboxes").status_code == 200 api_httpx.close() @@ -924,13 +1121,15 @@ async def test_async_closing_one_client_leaves_the_shared_pool_open( test_api_key, echo_server ): reset_transport_caches() - config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server, retries=1) api_httpx = get_async_api_client(config).get_async_httpx_client() envd_api = get_async_envd_api(config, echo_server) pool = get_async_pyqwest_transport(proxy_to_config(config.proxy)) try: - assert api_httpx._transport is envd_api._transport + assert isinstance(api_httpx._transport, AsyncRateLimitTransport) + assert isinstance(envd_api._transport, AsyncRateLimitTransport) + assert api_httpx._transport.transport is envd_api._transport.transport assert (await api_httpx.request("GET", "/sandboxes")).status_code == 200 await api_httpx.aclose() diff --git a/packages/python-sdk/tests/test_connection_config.py b/packages/python-sdk/tests/test_connection_config.py index 94443302e3..67d4c1f8d8 100644 --- a/packages/python-sdk/tests/test_connection_config.py +++ b/packages/python-sdk/tests/test_connection_config.py @@ -1,3 +1,5 @@ +import pytest + from e2b import ConnectionConfig @@ -230,3 +232,18 @@ def test_get_api_params_includes_sandbox_url(): # Per-call override takes priority. overridden = config.get_api_params(sandbox_url="https://sandbox.override.com") assert overridden["sandbox_url"] == "https://sandbox.override.com" + + +def test_retries_default_to_zero_and_propagate(): + config = ConnectionConfig(retries=5) + + assert ConnectionConfig().retries == 0 + assert config.retries == 5 + assert config.get_api_params()["retries"] == 5 + assert config.get_api_params(retries=0)["retries"] == 0 + + +@pytest.mark.parametrize("retries", [-1, 1.5, True]) +def test_retries_reject_invalid_values(retries): + with pytest.raises(ValueError): + ConnectionConfig(retries=retries) diff --git a/packages/python-sdk/tests/test_rate_limit_retry_transport.py b/packages/python-sdk/tests/test_rate_limit_retry_transport.py new file mode 100644 index 0000000000..f89aad4d1f --- /dev/null +++ b/packages/python-sdk/tests/test_rate_limit_retry_transport.py @@ -0,0 +1,484 @@ +import httpx +import pytest + +from e2b._retry import ( + REPLAYABLE_BODY_EXTENSION, + AsyncRateLimitTransport, + RateLimitTransport, + parse_retry_after, +) + + +class FakeTransport(httpx.BaseTransport): + def __init__(self, statuses, retry_after="0"): + self.statuses = iter(statuses) + self.retry_after = retry_after + self.requests = [] + self.responses = [] + self.closed = False + + def handle_request(self, request): + self.requests.append(request) + response = httpx.Response( + next(self.statuses), + headers={"Retry-After": self.retry_after} if self.retry_after else {}, + content=b"response", + request=request, + ) + self.responses.append(response) + return response + + def close(self): + self.closed = True + + +class FakeAsyncTransport(httpx.AsyncBaseTransport): + def __init__(self, statuses, retry_after="0"): + self.statuses = iter(statuses) + self.retry_after = retry_after + self.requests = [] + self.responses = [] + self.closed = False + + async def handle_async_request(self, request): + self.requests.append(request) + response = httpx.Response( + next(self.statuses), + headers={"Retry-After": self.retry_after} if self.retry_after else {}, + content=b"response", + request=request, + ) + self.responses.append(response) + return response + + async def aclose(self): + self.closed = True + + +class FakeClock: + def __init__(self): + self.now = 0.0 + self.sleeps = [] + + def monotonic(self): + return self.now + + def sleep(self, delay): + self.sleeps.append(delay) + self.now += delay + + +def test_retries_rate_limit_after_server_delay_and_replays_body(): + inner = FakeTransport([429, 200], retry_after="2") + sleeps = [] + request = httpx.Request("POST", "https://api.test", content=b"payload") + + response = RateLimitTransport(inner, retries=3, sleep=sleeps.append).handle_request( + request + ) + + assert response.status_code == 200 + assert sleeps == [2] + assert [item.content for item in inner.requests] == [b"payload", b"payload"] + assert inner.responses[0].is_closed + + +def test_retries_buffered_multipart_body(): + inner = FakeTransport([429, 200]) + request = httpx.Request( + "POST", + "https://api.test", + files={"file": ("test.txt", b"payload")}, + extensions={REPLAYABLE_BODY_EXTENSION: True}, + ) + + response = RateLimitTransport( + inner, retries=1, sleep=lambda _: None + ).handle_request(request) + + assert response.status_code == 200 + assert inner.requests[0].content == inner.requests[1].content + + +def test_exhaustion_returns_final_rate_limit_response(): + inner = FakeTransport([429, 429, 429]) + + response = RateLimitTransport( + inner, retries=2, sleep=lambda _: None + ).handle_request(httpx.Request("GET", "https://api.test")) + + assert response is inner.responses[-1] + assert len(inner.requests) == 3 + assert all(item.is_closed for item in inner.responses[:-1]) + assert response.content == b"response" + + +@pytest.mark.parametrize("retry_after", ["soon", "-1", "+1", "1.5"]) +def test_does_not_retry_without_delta_seconds(retry_after): + inner = FakeTransport([429], retry_after=retry_after) + + response = RateLimitTransport(inner, retries=3).handle_request( + httpx.Request("GET", "https://api.test") + ) + + assert response.status_code == 429 + assert len(inner.requests) == 1 + + +def test_429_without_retry_after_is_propagated_as_is(): + inner = FakeTransport([429], retry_after=None) + + response = RateLimitTransport(inner, retries=3).handle_request( + httpx.Request("GET", "https://api.test") + ) + + assert response is inner.responses[0] + assert len(inner.requests) == 1 + + +def test_retry_after_exceeding_timeout_is_propagated_as_is(): + inner = FakeTransport([429], retry_after="4") + sleeps = [] + request = httpx.Request( + "GET", + "https://api.test", + extensions={"timeout": {"connect": 3.0, "read": 3.0}}, + ) + + response = RateLimitTransport( + inner, retries=3, sleep=sleeps.append, monotonic=lambda: 0.0 + ).handle_request(request) + + assert response is inner.responses[0] + assert len(inner.requests) == 1 + assert sleeps == [] + + +def test_does_not_retry_other_status_codes(): + inner = FakeTransport([503], retry_after="0") + + response = RateLimitTransport(inner, retries=3).handle_request( + httpx.Request("GET", "https://api.test") + ) + + assert response.status_code == 503 + assert len(inner.requests) == 1 + + +def test_does_not_retry_when_cumulative_wait_reaches_request_timeout(): + inner = FakeTransport([429, 429], retry_after="2") + clock = FakeClock() + request = httpx.Request( + "GET", + "https://api.test", + extensions={ + "timeout": {"connect": 3.0, "read": 3.0, "write": 3.0, "pool": 3.0} + }, + ) + + response = RateLimitTransport( + inner, + retries=3, + sleep=clock.sleep, + monotonic=clock.monotonic, + ).handle_request(request) + + assert response.status_code == 429 + assert clock.sleeps == [2] + assert len(inner.requests) == 2 + + +def test_retry_uses_only_the_remaining_request_timeout(): + clock = FakeClock() + + class AdvancingTransport(FakeTransport): + def handle_request(self, request): + response = super().handle_request(request) + if len(self.requests) == 1: + clock.now = 2.0 + return response + + inner = AdvancingTransport([429, 200]) + request = httpx.Request( + "GET", + "https://api.test", + extensions={"timeout": {"connect": 3.0, "read": 3.0}}, + ) + + response = RateLimitTransport( + inner, retries=1, sleep=clock.sleep, monotonic=clock.monotonic + ).handle_request(request) + + assert response.status_code == 200 + assert inner.requests[1].extensions["timeout"] == { + "connect": 1.0, + "read": 1.0, + } + + +def test_retry_timeout_phases_fit_within_remaining_budget(): + clock = FakeClock() + + class AdvancingTransport(FakeTransport): + def handle_request(self, request): + response = super().handle_request(request) + if len(self.requests) == 1: + clock.now = 2.0 + return response + + inner = AdvancingTransport([429, 200]) + request = httpx.Request( + "GET", + "https://api.test", + extensions={ + "timeout": {"connect": 3.0, "read": 3.0, "write": 3.0, "pool": 3.0} + }, + ) + + RateLimitTransport( + inner, retries=1, sleep=clock.sleep, monotonic=clock.monotonic + ).handle_request(request) + + timeout = inner.requests[1].extensions["timeout"] + assert timeout["read"] + timeout["write"] == pytest.approx(1.0) + + +def test_zero_retries_passes_the_original_request_through(): + inner = FakeTransport([429], retry_after="0") + request = httpx.Request("GET", "https://api.test") + + response = RateLimitTransport(inner, retries=0).handle_request(request) + + assert response.status_code == 429 + assert inner.requests == [request] + + +def test_does_not_buffer_or_retry_streamed_body(): + class Stream(httpx.SyncByteStream): + def __iter__(self): + yield b"payload" + + inner = FakeTransport([429]) + request = httpx.Request("POST", "https://api.test", content=Stream()) + + response = RateLimitTransport(inner, retries=3).handle_request(request) + + assert response.status_code == 429 + assert inner.requests == [request] + + +def test_close_leaves_cached_transport_open(): + inner = FakeTransport([]) + + RateLimitTransport(inner, retries=1).close() + + assert not inner.closed + + +@pytest.mark.asyncio +async def test_async_retries_rate_limit_and_replays_body(): + inner = FakeAsyncTransport([429, 200], retry_after="2") + sleeps = [] + + async def sleep(delay): + sleeps.append(delay) + + response = await AsyncRateLimitTransport( + inner, retries=3, sleep=sleep + ).handle_async_request( + httpx.Request("POST", "https://api.test", content=b"payload") + ) + + assert response.status_code == 200 + assert sleeps == [2] + assert [item.content for item in inner.requests] == [b"payload", b"payload"] + assert inner.responses[0].is_closed + + +@pytest.mark.asyncio +async def test_async_retries_buffered_multipart_body(): + inner = FakeAsyncTransport([429, 200]) + request = httpx.Request( + "POST", + "https://api.test", + files={"file": ("test.txt", b"payload")}, + extensions={REPLAYABLE_BODY_EXTENSION: True}, + ) + + async def sleep(_): + pass + + response = await AsyncRateLimitTransport( + inner, retries=1, sleep=sleep + ).handle_async_request(request) + + assert response.status_code == 200 + assert inner.requests[0].content == inner.requests[1].content + + +@pytest.mark.asyncio +async def test_async_does_not_retry_when_wait_reaches_request_timeout(): + inner = FakeAsyncTransport([429], retry_after="3") + sleeps = [] + + async def sleep(delay): + sleeps.append(delay) + + response = await AsyncRateLimitTransport( + inner, retries=3, sleep=sleep, monotonic=lambda: 0.0 + ).handle_async_request( + httpx.Request( + "GET", + "https://api.test", + extensions={"timeout": {"read": 3.0}}, + ) + ) + + assert response.status_code == 429 + assert sleeps == [] + assert len(inner.requests) == 1 + + +@pytest.mark.asyncio +async def test_async_429_without_retry_after_is_propagated_as_is(): + inner = FakeAsyncTransport([429], retry_after=None) + + response = await AsyncRateLimitTransport(inner, retries=3).handle_async_request( + httpx.Request("GET", "https://api.test") + ) + + assert response is inner.responses[0] + assert len(inner.requests) == 1 + + +@pytest.mark.asyncio +async def test_async_retry_after_exceeding_timeout_is_propagated_as_is(): + inner = FakeAsyncTransport([429], retry_after="4") + sleeps = [] + + async def sleep(delay): + sleeps.append(delay) + + response = await AsyncRateLimitTransport( + inner, retries=3, sleep=sleep, monotonic=lambda: 0.0 + ).handle_async_request( + httpx.Request( + "GET", + "https://api.test", + extensions={"timeout": {"connect": 3.0, "read": 3.0}}, + ) + ) + + assert response is inner.responses[0] + assert len(inner.requests) == 1 + assert sleeps == [] + + +@pytest.mark.asyncio +async def test_async_retry_uses_only_the_remaining_request_timeout(): + clock = FakeClock() + + class AdvancingTransport(FakeAsyncTransport): + async def handle_async_request(self, request): + response = await super().handle_async_request(request) + if len(self.requests) == 1: + clock.now = 2.0 + return response + + inner = AdvancingTransport([429, 200]) + + async def sleep(delay): + clock.sleep(delay) + + response = await AsyncRateLimitTransport( + inner, retries=1, sleep=sleep, monotonic=clock.monotonic + ).handle_async_request( + httpx.Request( + "GET", + "https://api.test", + extensions={"timeout": {"connect": 3.0, "read": 3.0}}, + ) + ) + + assert response.status_code == 200 + assert inner.requests[1].extensions["timeout"] == { + "connect": 1.0, + "read": 1.0, + } + + +@pytest.mark.asyncio +async def test_async_retry_timeout_phases_fit_within_remaining_budget(): + clock = FakeClock() + + class AdvancingTransport(FakeAsyncTransport): + async def handle_async_request(self, request): + response = await super().handle_async_request(request) + if len(self.requests) == 1: + clock.now = 2.0 + return response + + inner = AdvancingTransport([429, 200]) + + async def sleep(delay): + clock.sleep(delay) + + await AsyncRateLimitTransport( + inner, retries=1, sleep=sleep, monotonic=clock.monotonic + ).handle_async_request( + httpx.Request( + "GET", + "https://api.test", + extensions={ + "timeout": { + "connect": 3.0, + "read": 3.0, + "write": 3.0, + "pool": 3.0, + } + }, + ) + ) + + timeout = inner.requests[1].extensions["timeout"] + assert timeout["read"] + timeout["write"] == pytest.approx(1.0) + + +@pytest.mark.asyncio +async def test_async_does_not_retry_streamed_body(): + class Stream(httpx.AsyncByteStream): + async def __aiter__(self): + yield b"payload" + + inner = FakeAsyncTransport([429]) + request = httpx.Request("POST", "https://api.test", content=Stream()) + + response = await AsyncRateLimitTransport(inner, retries=3).handle_async_request( + request + ) + + assert response.status_code == 429 + assert inner.requests == [request] + + +@pytest.mark.asyncio +async def test_async_close_leaves_cached_transport_open(): + inner = FakeAsyncTransport([]) + + await AsyncRateLimitTransport(inner, retries=1).aclose() + + assert not inner.closed + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("0", 0), + (" 60 ", 60), + ("2147483648", None), + ("9" * 10_000, None), + ("Wed, 21 Oct 2015 07:28:00 GMT", None), + ], +) +def test_parse_retry_after_delta_seconds(value, expected): + assert parse_retry_after(value) == expected diff --git a/packages/python-sdk/tests/test_volume_client.py b/packages/python-sdk/tests/test_volume_client.py index 6429daca2e..e461a1b3f5 100644 --- a/packages/python-sdk/tests/test_volume_client.py +++ b/packages/python-sdk/tests/test_volume_client.py @@ -10,6 +10,7 @@ from transport_caches import reset_transport_caches +from e2b._retry import AsyncRateLimitTransport, RateLimitTransport import e2b.api.client_async as api_client_async import e2b.api.client_sync as api_client_sync import e2b.volume.client_async as client_async @@ -78,6 +79,66 @@ async def run(): asyncio.run(run()) +def test_volume_instances_propagate_and_override_retries(): + volume = Volume("v1", "test", token="vol-token", retries=2) + async_volume = AsyncVolume("v1", "test", token="vol-token", retries=2) + + assert volume._get_volume_config().retries == 2 + assert volume._get_volume_config(retries=1).retries == 1 + assert async_volume._get_volume_config().retries == 2 + assert async_volume._get_volume_config(retries=1).retries == 1 + + +def test_sync_volume_transport_retries_rate_limit(monkeypatch): + attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + return httpx.Response( + 429 if attempts == 1 else 200, + headers={"Retry-After": "0"}, + ) + + monkeypatch.setattr( + client_sync, + "get_httpx_transport", + lambda *_args, **_kwargs: httpx.MockTransport(handler), + ) + transport = get_sync_transport(VolumeConnectionConfig(token="vol-token", retries=1)) + + with httpx.Client(transport=transport) as client: + assert client.get("https://volume.e2b.test/file").status_code == 200 + assert attempts == 2 + + +@pytest.mark.asyncio +async def test_async_volume_transport_retries_rate_limit(monkeypatch): + attempts = 0 + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + return httpx.Response( + 429 if attempts == 1 else 200, + headers={"Retry-After": "0"}, + ) + + monkeypatch.setattr( + client_async, + "get_httpx_transport", + lambda *_args, **_kwargs: httpx.MockTransport(handler), + ) + transport = get_async_transport( + VolumeConnectionConfig(token="vol-token", retries=1) + ) + + async with httpx.AsyncClient(transport=transport) as client: + response = await client.get("https://volume.e2b.test/file") + assert response.status_code == 200 + assert attempts == 2 + + def test_sync_transport_is_cached_per_proxy(): reset_volume_transports() config = VolumeConnectionConfig(token="vol-token") @@ -88,9 +149,12 @@ def test_sync_transport_is_cached_per_proxy(): transport_b = get_sync_transport(config) transport_c = get_sync_transport(proxied) - assert isinstance(transport_a, PyqwestTransport) - assert transport_a is transport_b - assert transport_a is not transport_c + assert isinstance(transport_a, RateLimitTransport) + assert isinstance(transport_b, RateLimitTransport) + assert isinstance(transport_c, RateLimitTransport) + assert isinstance(transport_a.transport, PyqwestTransport) + assert transport_a.transport is transport_b.transport + assert transport_a.transport is not transport_c.transport finally: reset_volume_transports() @@ -106,14 +170,33 @@ def test_volume_transports_are_the_shared_sdk_pools(test_api_key): api_config = ConnectionConfig(api_key=test_api_key) try: - assert get_sync_transport(config) is api_client_sync.get_transport(api_config) - assert get_sync_streaming_transport(config) is api_client_sync.get_transport( + api_transport = api_client_sync.get_transport(api_config) + streaming_api_transport = api_client_sync.get_transport( api_config, for_streaming=True ) - assert get_async_transport(config) is api_client_async.get_transport(api_config) - assert get_async_streaming_transport(config) is api_client_async.get_transport( + assert isinstance(api_transport, RateLimitTransport) + assert isinstance(streaming_api_transport, RateLimitTransport) + volume_transport = get_sync_transport(config) + streaming_volume_transport = get_sync_streaming_transport(config) + assert isinstance(volume_transport, RateLimitTransport) + assert isinstance(streaming_volume_transport, RateLimitTransport) + assert volume_transport.transport is api_transport.transport + assert streaming_volume_transport.transport is streaming_api_transport.transport + async_api_transport = api_client_async.get_transport(api_config) + streaming_async_api_transport = api_client_async.get_transport( api_config, for_streaming=True ) + assert isinstance(async_api_transport, AsyncRateLimitTransport) + assert isinstance(streaming_async_api_transport, AsyncRateLimitTransport) + volume_async_transport = get_async_transport(config) + streaming_volume_async_transport = get_async_streaming_transport(config) + assert isinstance(volume_async_transport, AsyncRateLimitTransport) + assert isinstance(streaming_volume_async_transport, AsyncRateLimitTransport) + assert volume_async_transport.transport is async_api_transport.transport + assert ( + streaming_volume_async_transport.transport + is streaming_async_api_transport.transport + ) finally: reset_volume_transports() @@ -136,7 +219,9 @@ def worker(): thread.start() thread.join() - assert result["transport"] is main_transport + assert isinstance(main_transport, RateLimitTransport) + assert isinstance(result["transport"], RateLimitTransport) + assert result["transport"].transport is main_transport.transport finally: reset_volume_transports() @@ -156,12 +241,16 @@ async def get_transports(): transport_b1, _ = asyncio.run(get_transports()) proxied_transport = get_async_transport(proxied) - assert isinstance(transport_a1, AsyncPyqwestTransport) - assert transport_a1 is transport_a2 - assert transport_a1 is transport_b1 + assert isinstance(transport_a1, AsyncRateLimitTransport) + assert isinstance(transport_a2, AsyncRateLimitTransport) + assert isinstance(transport_b1, AsyncRateLimitTransport) + assert isinstance(transport_a1.transport, AsyncPyqwestTransport) + assert transport_a1.transport is transport_a2.transport + assert transport_a1.transport is transport_b1.transport # Different proxy still gets its own transport. - assert proxied_transport is not transport_a1 + assert isinstance(proxied_transport, AsyncRateLimitTransport) + assert proxied_transport.transport is not transport_a1.transport finally: reset_volume_transports() @@ -334,13 +423,21 @@ def test_stream_transport_is_separate_from_regular_transport(): try: regular = get_sync_transport(config) streaming = get_sync_streaming_transport(config) - assert regular is not streaming - assert get_sync_transport(config) is regular - assert get_sync_streaming_transport(config) is streaming + assert isinstance(regular, RateLimitTransport) + assert isinstance(streaming, RateLimitTransport) + assert regular.transport is not streaming.transport + next_regular = get_sync_transport(config) + next_streaming = get_sync_streaming_transport(config) + assert isinstance(next_regular, RateLimitTransport) + assert isinstance(next_streaming, RateLimitTransport) + assert next_regular.transport is regular.transport + assert next_streaming.transport is streaming.transport async_regular = get_async_transport(config) async_streaming = get_async_streaming_transport(config) - assert async_regular is not async_streaming + assert isinstance(async_regular, AsyncRateLimitTransport) + assert isinstance(async_streaming, AsyncRateLimitTransport) + assert async_regular.transport is not async_streaming.transport finally: reset_volume_transports() diff --git a/packages/python-sdk/tests/test_volume_connection_config.py b/packages/python-sdk/tests/test_volume_connection_config.py index 61b54847fe..4a554f63a3 100644 --- a/packages/python-sdk/tests/test_volume_connection_config.py +++ b/packages/python-sdk/tests/test_volume_connection_config.py @@ -1,3 +1,5 @@ +import pytest + from e2b.volume.connection_config import VolumeConnectionConfig @@ -89,3 +91,23 @@ def test_volume_request_timeout_in_args(): def test_volume_request_timeout_zero_disables_timeout(): config = VolumeConnectionConfig(request_timeout=0) assert config.request_timeout is None + + +def test_volume_retries_default_to_zero(): + assert VolumeConnectionConfig().retries == 0 + + +def test_volume_retries_in_args(): + assert VolumeConnectionConfig(retries=2).retries == 2 + + +def test_volume_retries_are_inherited_and_overridden(): + config = VolumeConnectionConfig(retries=2) + assert config.get_api_params()["retries"] == 2 + assert config.get_api_params(retries=1)["retries"] == 1 + + +@pytest.mark.parametrize("retries", [-1, 1.5, True]) +def test_volume_retries_reject_invalid_values(retries): + with pytest.raises(ValueError, match="non-negative integer"): + VolumeConnectionConfig(retries=retries) From 2f4b974c95df42b623ef49c0c5a020ee7d0ead60 Mon Sep 17 00:00:00 2001 From: nalekseev-e2b Date: Tue, 8 Sep 2026 17:59:08 +0200 Subject: [PATCH 02/16] fix(sdks): limit rate-limit retries to control plane --- .changeset/retry-after-rate-limits.md | 2 +- packages/js-sdk/src/connectionConfig.ts | 4 +- packages/js-sdk/src/envd/api.ts | 37 +-- packages/js-sdk/src/retry.ts | 2 +- .../js-sdk/src/sandbox/filesystem/index.ts | 22 +- packages/js-sdk/src/sandbox/index.ts | 9 +- packages/js-sdk/src/sandbox/sandboxApi.ts | 1 + packages/js-sdk/src/volume/client.ts | 17 +- packages/js-sdk/src/volume/index.ts | 17 +- packages/js-sdk/tests/client.test.ts | 1 - .../js-sdk/tests/connectionConfig.test.ts | 4 +- packages/js-sdk/tests/retry.test.ts | 28 +- .../tests/sandbox/configPropagation.test.ts | 2 + packages/js-sdk/tests/volume/file.test.ts | 43 +-- packages/python-sdk/e2b/_retry.py | 15 +- .../e2b/api/client_async/__init__.py | 20 +- .../e2b/api/client_sync/__init__.py | 20 +- packages/python-sdk/e2b/connection_config.py | 4 +- .../sandbox_async/filesystem/filesystem.py | 7 +- .../e2b/sandbox_sync/filesystem/filesystem.py | 7 +- .../e2b/volume/client_async/__init__.py | 14 +- .../e2b/volume/client_sync/__init__.py | 14 +- .../e2b/volume/connection_config.py | 9 - .../python-sdk/e2b/volume/volume_async.py | 9 - packages/python-sdk/e2b/volume/volume_sync.py | 9 - .../tests/test_api_client_transport.py | 283 +++++++----------- .../tests/test_connection_config.py | 4 +- .../tests/test_rate_limit_retry_transport.py | 45 +-- .../python-sdk/tests/test_volume_client.py | 107 ++----- .../tests/test_volume_connection_config.py | 22 -- 30 files changed, 241 insertions(+), 537 deletions(-) diff --git a/.changeset/retry-after-rate-limits.md b/.changeset/retry-after-rate-limits.md index 93b4cc7ffb..13f2ee000e 100644 --- a/.changeset/retry-after-rate-limits.md +++ b/.changeset/retry-after-rate-limits.md @@ -3,4 +3,4 @@ "e2b": patch --- -Allow HTTP requests to opt into retries after `429` responses using the server's delta-seconds `Retry-After` delay. Retries remain disabled by default, are skipped for streamed uploads, and stop when waiting would exhaust the request timeout. +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. diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index 88e656367d..7d91c2a938 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -60,9 +60,9 @@ export interface ConnectionOpts { */ requestTimeoutMs?: number /** - * Number of retries after a 429 response with `Retry-After`. + * Number of control-plane API retries after a 429 response with `Retry-After`. * - * @default 0 + * @default 3 */ retries?: number /** diff --git a/packages/js-sdk/src/envd/api.ts b/packages/js-sdk/src/envd/api.ts index 5ed1b1186d..673648c886 100644 --- a/packages/js-sdk/src/envd/api.ts +++ b/packages/js-sdk/src/envd/api.ts @@ -1,9 +1,8 @@ import createClient from 'openapi-fetch' import type { components, paths } from './schema.gen' -import { ConnectionConfig, REQUEST_TIMEOUT_MS } from '../connectionConfig' +import { ConnectionConfig } from '../connectionConfig' import { createApiLogger } from '../logs' -import { resolveRetries, withRateLimitRetry } from '../retry' import { SandboxError, InvalidArgumentError, @@ -194,31 +193,23 @@ export async function handleWatchDirStartEvent( class EnvdApiClient { readonly api: ReturnType> readonly version: string - readonly fetch: typeof fetch - - private readonly retries: number - private readonly requestTimeoutMs: number constructor( - config: Pick & - Partial> & { - /** - * Sandbox-scoped envd access token, sent as the `X-Access-Token` header. - */ - envdAccessToken?: string - fetch?: typeof fetch - headers?: Record - }, + config: Pick & { + /** + * Sandbox-scoped envd access token, sent as the `X-Access-Token` header. + */ + envdAccessToken?: string + fetch?: (request: Request) => ReturnType + headers?: Record + }, metadata: { version: string } ) { - this.fetch = config.fetch ?? fetch - this.retries = config.retries ?? 0 - this.requestTimeoutMs = config.requestTimeoutMs ?? REQUEST_TIMEOUT_MS this.api = createClient({ baseUrl: config.apiUrl, - fetch: this.getFetch(), + fetch: config?.fetch, headers: { ...config?.headers, ...(config.envdAccessToken && { @@ -234,14 +225,6 @@ class EnvdApiClient { this.api.use(createApiLogger(config.logger)) } } - - getFetch(retries?: number, requestTimeoutMs?: number): typeof fetch { - return withRateLimitRetry( - this.fetch, - resolveRetries(retries ?? this.retries), - requestTimeoutMs ?? this.requestTimeoutMs - ) - } } export type { components, paths } diff --git a/packages/js-sdk/src/retry.ts b/packages/js-sdk/src/retry.ts index d0af634495..a48d9d7a85 100644 --- a/packages/js-sdk/src/retry.ts +++ b/packages/js-sdk/src/retry.ts @@ -1,7 +1,7 @@ const MAX_RETRY_AFTER_SECONDS = 2_147_483 export function resolveRetries(retries?: number): number { - if (retries === undefined) return 0 + if (retries === undefined) return 3 if (!Number.isInteger(retries) || retries < 0) { throw new Error( `Invalid retries=${retries}: expected a non-negative integer.` diff --git a/packages/js-sdk/src/sandbox/filesystem/index.ts b/packages/js-sdk/src/sandbox/filesystem/index.ts index fd53bc2c33..c07a12ae0b 100644 --- a/packages/js-sdk/src/sandbox/filesystem/index.ts +++ b/packages/js-sdk/src/sandbox/filesystem/index.ts @@ -257,7 +257,7 @@ export function mapEntryInfo(entry: FsEntryInfo): EntryInfo { * Options for the sandbox filesystem operations. */ export interface FilesystemRequestOpts extends Partial< - Pick + Pick > { /** * User to use for the operation in the sandbox. @@ -494,7 +494,6 @@ export class Filesystem { parseAs: 'stream', signal: controller.signal, headers, - fetch: this.envdApi.getFetch(opts?.retries, requestTimeoutMs), }) .catch(async (err) => { // Map a dropped connection during the handshake (e.g. killed @@ -531,8 +530,6 @@ export class Filesystem { } } - const requestTimeoutMs = - opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs const res = await this.envdApi.api .GET('/files', { params: { @@ -542,9 +539,11 @@ export class Filesystem { }, }, parseAs: format === 'bytes' ? 'arrayBuffer' : format, - signal: this.connectionConfig.getSignal(requestTimeoutMs, opts?.signal), + signal: this.connectionConfig.getSignal( + opts?.requestTimeoutMs, + opts?.signal + ), headers, - fetch: this.envdApi.getFetch(opts?.retries, requestTimeoutMs), }) .catch(async (err) => { throw await handleEnvdApiFetchError(err, this.checkHealth) @@ -696,9 +695,6 @@ export class Filesystem { writeOpts?.requestTimeoutMs, writeOpts?.signal ) - const requestTimeoutMs = - writeOpts?.requestTimeoutMs ?? - this.connectionConfig.requestTimeoutMs const res = await this.envdApi.api .POST('/files', { @@ -711,9 +707,6 @@ export class Filesystem { bodySerializer: () => body, headers, signal, - fetch: streamed - ? this.envdApi.fetch - : this.envdApi.getFetch(writeOpts?.retries, requestTimeoutMs), body: {}, // Streaming request bodies require half-duplex mode. ...(streamed && { @@ -757,8 +750,6 @@ export class Filesystem { ) } - const requestTimeoutMs = - writeOpts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs const res = await this.envdApi.api .POST('/files', { params: { @@ -770,10 +761,9 @@ export class Filesystem { bodySerializer: () => formData, headers: extraHeaders, signal: this.connectionConfig.getSignal( - requestTimeoutMs, + writeOpts?.requestTimeoutMs, writeOpts?.signal ), - fetch: this.envdApi.getFetch(writeOpts?.retries, requestTimeoutMs), body: {}, }) .catch(async (err) => { diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index 94749b474a..6dd4557436 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -210,8 +210,6 @@ export class Sandbox extends SandboxApi { ...sandboxHeaders, }, fetch: (request) => envdFetch(request), - retries: this.connectionConfig.retries, - requestTimeoutMs: this.connectionConfig.requestTimeoutMs, }, { version: opts.envdVersion, @@ -553,18 +551,15 @@ export class Sandbox extends SandboxApi { * ``` */ async isRunning( - opts?: Pick + opts?: Pick ): Promise { - const requestTimeoutMs = - opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs const signal = this.connectionConfig.getSignal( - requestTimeoutMs, + opts?.requestTimeoutMs, opts?.signal ) const res = await this.envdApi.api.GET('/health', { signal, - fetch: this.envdApi.getFetch(opts?.retries, requestTimeoutMs), }) if (res.response.status == 502) { diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 2a4ba298fc..9a78724805 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -496,6 +496,7 @@ export interface SandboxApiOpts extends Partial< | 'debug' | 'domain' | 'requestTimeoutMs' + | 'retries' | 'signal' > > {} diff --git a/packages/js-sdk/src/volume/client.ts b/packages/js-sdk/src/volume/client.ts index f11f6843ec..d63057b597 100644 --- a/packages/js-sdk/src/volume/client.ts +++ b/packages/js-sdk/src/volume/client.ts @@ -5,7 +5,6 @@ import { defaultHeaders, getEnvVar } from '../api/metadata' import { createApiFetch } from '../api/http2' import { buildRequestSignal } from '../connectionConfig' import { createApiLogger, Logger } from '../logs' -import { resolveRetries, withRateLimitRetry } from '../retry' import type { Volume } from './index' const REQUEST_TIMEOUT_MS = 60_000 // 60 seconds @@ -42,12 +41,6 @@ export interface VolumeApiOpts { * @default 60_000 // 60 seconds */ requestTimeoutMs?: number - /** - * Number of retries after a 429 response with `Retry-After`. - * - * @default 0 - */ - retries?: number /** * Logger to use for logging messages. It can accept any object that implements `Logger` interface—for example, {@link console}. */ @@ -81,7 +74,6 @@ export class VolumeConnectionConfig { readonly headers?: Record readonly logger?: Logger readonly requestTimeoutMs: number - readonly retries: number readonly signal?: AbortSignal readonly proxy?: string @@ -96,7 +88,6 @@ export class VolumeConnectionConfig { this.headers = opts?.headers this.logger = opts?.logger this.requestTimeoutMs = opts?.requestTimeoutMs ?? REQUEST_TIMEOUT_MS - this.retries = resolveRetries(opts?.retries ?? volume.retries) this.signal = opts?.signal this.proxy = opts?.proxy || volume.proxy } @@ -126,17 +117,11 @@ export class VolumeConnectionConfig { */ class VolumeApiClient { readonly api: ReturnType> - readonly fetch: typeof fetch constructor(config: VolumeConnectionConfig) { - this.fetch = createApiFetch(config.proxy) this.api = createClient({ baseUrl: config.apiUrl, - fetch: withRateLimitRetry( - this.fetch, - config.retries, - config.requestTimeoutMs - ), + fetch: createApiFetch(config.proxy), headers: { ...defaultHeaders, ...(config.token && { Authorization: `Bearer ${config.token}` }), diff --git a/packages/js-sdk/src/volume/index.ts b/packages/js-sdk/src/volume/index.ts index 75114ade77..4563d0f9d8 100644 --- a/packages/js-sdk/src/volume/index.ts +++ b/packages/js-sdk/src/volume/index.ts @@ -82,11 +82,6 @@ export class Volume extends ClientFactory { */ readonly proxy?: string - /** - * Number of retries after a 429 response with `Retry-After`. - */ - readonly retries?: number - /** * Create a local Volume instance with no API call. * @@ -96,7 +91,6 @@ export class Volume extends ClientFactory { * @param domain domain for the volume API. * @param debug whether to use debug mode. * @param proxy proxy URL for the volume content API. - * @param retries number of retries after rate limiting. */ constructor( volumeId: string, @@ -104,8 +98,7 @@ export class Volume extends ClientFactory { token: string, domain?: string, debug?: boolean, - proxy?: string, - retries?: number + proxy?: string ) { super() this.volumeId = volumeId @@ -114,7 +107,6 @@ export class Volume extends ClientFactory { this.domain = domain this.debug = debug this.proxy = proxy - this.retries = retries } /** @@ -156,8 +148,7 @@ export class Volume extends ClientFactory { res.data.token, res.data.domain || config.domain, config.debug, - config.proxy, - config.retries + config.proxy ) as InstanceType } @@ -183,8 +174,7 @@ export class Volume extends ClientFactory { token, domain ?? config.domain, config.debug, - config.proxy, - config.retries + config.proxy ) as InstanceType } @@ -718,7 +708,6 @@ export class Volume extends ClientFactory { 'Content-Type': 'application/octet-stream', }, signal, - ...(streamed && { fetch: client.fetch }), // Streaming request bodies require half-duplex mode. ...(streamed && { duplex: 'half' as const }), }) diff --git a/packages/js-sdk/tests/client.test.ts b/packages/js-sdk/tests/client.test.ts index db210368d4..44b64e3932 100644 --- a/packages/js-sdk/tests/client.test.ts +++ b/packages/js-sdk/tests/client.test.ts @@ -184,7 +184,6 @@ test('client retries rate-limited control-plane requests', async () => { const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, - retries: 1, }) await client.Sandbox.list().nextItems() diff --git a/packages/js-sdk/tests/connectionConfig.test.ts b/packages/js-sdk/tests/connectionConfig.test.ts index 22475dfaae..a82a44bd67 100644 --- a/packages/js-sdk/tests/connectionConfig.test.ts +++ b/packages/js-sdk/tests/connectionConfig.test.ts @@ -42,8 +42,8 @@ test('api_url defaults correctly', () => { assert.equal(config.apiUrl, 'https://api.e2b.app') }) -test('retries default to zero and accept a non-negative integer', () => { - assert.equal(new ConnectionConfig().retries, 0) +test('retries default to three and accept a non-negative integer', () => { + assert.equal(new ConnectionConfig().retries, 3) assert.equal(new ConnectionConfig({ retries: 2 }).retries, 2) assert.throws(() => new ConnectionConfig({ retries: -1 })) }) diff --git a/packages/js-sdk/tests/retry.test.ts b/packages/js-sdk/tests/retry.test.ts index 908aea73f3..1c8567b4fc 100644 --- a/packages/js-sdk/tests/retry.test.ts +++ b/packages/js-sdk/tests/retry.test.ts @@ -5,10 +5,11 @@ import { resolveRetries, withRateLimitRetry, } from '../src/retry' +import { EnvdApiClient } from '../src/envd/api' describe('resolveRetries', () => { - test('defaults to zero', () => { - expect(resolveRetries()).toBe(0) + test('defaults to three', () => { + expect(resolveRetries()).toBe(3) }) test.each([-1, 1.5, Number.NaN])('rejects %s', (retries) => { @@ -152,3 +153,26 @@ test('disabled retries pass a streaming request through unchanged', async () => expect(fetchImpl).toHaveBeenCalledWith('https://api.e2b.test/resource', init) expect(body.locked).toBe(false) }) + +test('envd clients do not retry rate-limited requests', async () => { + const fetchImpl = vi.fn( + async () => + new Response(null, { + status: 429, + headers: { 'Retry-After': '0' }, + }) + ) as typeof fetch + const client = new EnvdApiClient( + { + apiUrl: 'https://envd.e2b.test', + logger: undefined, + fetch: fetchImpl, + }, + { version: '0.0.0' } + ) + + const response = await client.api.GET('/health') + + expect(response.response.status).toBe(429) + expect(fetchImpl).toHaveBeenCalledOnce() +}) diff --git a/packages/js-sdk/tests/sandbox/configPropagation.test.ts b/packages/js-sdk/tests/sandbox/configPropagation.test.ts index 0b1edb7196..98ec5017f0 100644 --- a/packages/js-sdk/tests/sandbox/configPropagation.test.ts +++ b/packages/js-sdk/tests/sandbox/configPropagation.test.ts @@ -70,12 +70,14 @@ describe('Sandbox API config propagation', () => { await sandbox.pause({ domain: 'override.e2b.dev', requestTimeoutMs: 9999, + retries: 0, }) const opts = pauseSpy.mock.calls[0][1] assert.equal(opts?.apiKey, baseConfig.apiKey) assert.equal(opts?.domain, 'override.e2b.dev') assert.equal(opts?.requestTimeoutMs, 9999) + assert.equal(opts?.retries, 0) assert.equal(opts?.debug, baseConfig.debug) }) diff --git a/packages/js-sdk/tests/volume/file.test.ts b/packages/js-sdk/tests/volume/file.test.ts index ad224d8589..b35efa8675 100644 --- a/packages/js-sdk/tests/volume/file.test.ts +++ b/packages/js-sdk/tests/volume/file.test.ts @@ -22,45 +22,7 @@ beforeEach(() => server.resetHandlers(...createMockVolumeApi())) describe('Volume File Operations', () => { describe('writeFile and readFile', () => { volumeTest( - 'retries a buffered upload after rate limiting', - async ({ volume }) => { - let attempts = 0 - server.use( - http.put(apiUrl('/volumecontent/:volumeID/file'), () => { - attempts++ - if (attempts === 1) { - return new HttpResponse(null, { - status: 429, - headers: { 'Retry-After': '0' }, - }) - } - const timestamp = new Date().toISOString() - return HttpResponse.json({ - name: 'retry.txt', - type: VolumeFileType.FILE, - path: '/retry.txt', - size: 5, - mode: 0o644, - uid: 0, - gid: 0, - atime: timestamp, - mtime: timestamp, - ctime: timestamp, - }) - }) - ) - - const stat = await volume.writeFile('/retry.txt', 'retry', { - retries: 1, - }) - - expect(stat.path).toBe('/retry.txt') - expect(attempts).toBe(2) - } - ) - - volumeTest( - 'does not retry a streamed upload after rate limiting', + 'does not retry a buffered upload after rate limiting', async ({ volume }) => { let attempts = 0 server.use( @@ -75,10 +37,9 @@ describe('Volume File Operations', () => { ) }) ) - const stream = new Blob(['streamed']).stream() await expect( - volume.writeFile('/stream.txt', stream, { retries: 1 }) + volume.writeFile('/retry.txt', 'retry') ).rejects.toBeInstanceOf(RateLimitError) expect(attempts).toBe(1) } diff --git a/packages/python-sdk/e2b/_retry.py b/packages/python-sdk/e2b/_retry.py index 64cfbcb574..e58aa51612 100644 --- a/packages/python-sdk/e2b/_retry.py +++ b/packages/python-sdk/e2b/_retry.py @@ -5,12 +5,11 @@ import httpx MAX_RETRY_AFTER_SECONDS = 2_147_483_647 -REPLAYABLE_BODY_EXTENSION = "e2b_replayable_body" def resolve_max_retries(retries: Optional[int]) -> int: if retries is None: - return 0 + return 3 if isinstance(retries, bool) or not isinstance(retries, int) or retries < 0: raise ValueError( f"Invalid retries={retries!r}: expected a non-negative integer." @@ -91,11 +90,7 @@ def __init__( def handle_request(self, request: httpx.Request) -> httpx.Response: # Iterators, files, and other streaming bodies may be consumed by the # first attempt and cannot be safely replayed without buffering them. - replayable = ( - isinstance(request.stream, httpx.ByteStream) - or request.extensions.get(REPLAYABLE_BODY_EXTENSION) is True - ) - if self.retries == 0 or not replayable: + if self.retries == 0 or not isinstance(request.stream, httpx.ByteStream): return self.transport.handle_request(request) request.read() @@ -150,11 +145,7 @@ def __init__( async def handle_async_request(self, request: httpx.Request) -> httpx.Response: # Iterators, files, and other streaming bodies may be consumed by the # first attempt and cannot be safely replayed without buffering them. - replayable = ( - isinstance(request.stream, httpx.ByteStream) - or request.extensions.get(REPLAYABLE_BODY_EXTENSION) is True - ) - if self.retries == 0 or not replayable: + if self.retries == 0 or not isinstance(request.stream, httpx.ByteStream): return await self.transport.handle_async_request(request) await request.aread() diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index efc95d2eed..2b088412b9 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -22,7 +22,11 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient: - return AsyncApiClient(config, transport=get_transport(config), **kwargs) + return AsyncApiClient( + config, + transport=AsyncRateLimitTransport(get_transport(config), config.retries), + **kwargs, + ) class ConnectionRetryTransport(RetryTransport): @@ -153,18 +157,13 @@ def get_transport( *, for_streaming: bool = False, pool_shard: int = 0, -) -> httpx.AsyncBaseTransport: - """The httpx transport factory for the control-plane REST API and +) -> AsyncPyqwestTransport: + """The shared httpx transport factory for the control-plane REST API and envd HTTP API (file transfers, health checks). Generic callers use shard zero; :func:`get_envd_transport` supplies a sandbox-specific shard. For TLS connections ALPN negotiates the HTTP version (HTTP/2 against the E2B API), like the http2-enabled httpx transport this replaced. - The pyqwest adapter and its connection pool are shared. Rate-limit policy - is applied in a per-call wrapper so clients with different retry settings - can still reuse the same pool; the wrapper delegates directly when retries - are disabled. - ``http2=False`` returns a separate transport (its own pool) pinned to HTTP/1.1. That matters for a server that reacts to a client going away: HTTP/2 multiplexes requests over one connection, so abandoning a request @@ -180,18 +179,17 @@ def get_transport( downloads take it, and they get their own pool (see :func:`get_pyqwest_transport`). """ - transport = get_httpx_transport( + return get_httpx_transport( proxy_to_config(config.proxy), READ_TIMEOUT if for_streaming else None, http2, pool_shard, ) - return AsyncRateLimitTransport(transport, config.retries) def get_envd_transport( config: ConnectionConfig, http2: bool = True, *, for_streaming: bool = False -) -> httpx.AsyncBaseTransport: +) -> AsyncPyqwestTransport: """The envd HTTP API's transport, sharded by sandbox ID. Envd RPC and non-streaming HTTP traffic for one sandbox resolve the same diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index 16a6e36010..5c1d553116 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -22,7 +22,11 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient: - return ApiClient(config, transport=get_transport(config), **kwargs) + return ApiClient( + config, + transport=RateLimitTransport(get_transport(config), config.retries), + **kwargs, + ) class ConnectionRetryTransport(SyncRetryTransport): @@ -152,18 +156,13 @@ def get_transport( *, for_streaming: bool = False, pool_shard: int = 0, -) -> httpx.BaseTransport: - """The httpx transport factory for the control-plane REST API and +) -> PyqwestTransport: + """The shared httpx transport factory for the control-plane REST API and envd HTTP API (file transfers, health checks). Generic callers use shard zero; :func:`get_envd_transport` supplies a sandbox-specific shard. For TLS connections ALPN negotiates the HTTP version (HTTP/2 against the E2B API), like the http2-enabled httpx transport this replaced. - The pyqwest adapter and its connection pool are shared. Rate-limit policy - is applied in a per-call wrapper so clients with different retry settings - can still reuse the same pool; the wrapper delegates directly when retries - are disabled. - ``http2=False`` returns a separate transport (its own pool) pinned to HTTP/1.1. That matters for a server that reacts to a client going away: HTTP/2 multiplexes requests over one connection, so abandoning a request @@ -179,18 +178,17 @@ def get_transport( downloads take it, and they get their own pool (see :func:`get_pyqwest_transport`). """ - transport = get_httpx_transport( + return get_httpx_transport( proxy_to_config(config.proxy), READ_TIMEOUT if for_streaming else None, http2, pool_shard, ) - return RateLimitTransport(transport, config.retries) def get_envd_transport( config: ConnectionConfig, http2: bool = True, *, for_streaming: bool = False -) -> httpx.BaseTransport: +) -> PyqwestTransport: """The envd HTTP API's transport, sharded by sandbox ID. Envd RPC and non-streaming HTTP traffic for one sandbox resolve the same diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 70f3aa0a59..8a3bcd08eb 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -50,9 +50,9 @@ class ApiParams(TypedDict, total=False): """Timeout for the request in **seconds**, defaults to 60 seconds.""" retries: Optional[int] - """Number of HTTP retries after a 429 with ``Retry-After``. + """Number of control-plane HTTP retries after a 429 with ``Retry-After``. A retry is skipped when its wait would exhaust the request timeout. - Defaults to no retries.""" + Defaults to 3 retries.""" headers: Optional[Dict[str, str]] """Additional headers to send with the request. Deprecated, use api_headers instead.""" diff --git a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py index db5e320539..725662fad0 100644 --- a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py @@ -7,7 +7,6 @@ from connectrpc.errors import ConnectError from packaging.version import Version -from e2b._retry import REPLAYABLE_BODY_EXTENSION from e2b.api.client_async import get_envd_api from e2b.connection_config import ( KEEPALIVE_PING_HEADER, @@ -449,7 +448,6 @@ async def _upload_file(file): if len(httpx_files) == 0: return [] - body_is_streamed = multipart_body_is_streamed(files) try: r = await self._envd_api.post( ENVD_API_FILES_ROUTE, @@ -460,8 +458,9 @@ async def _upload_file(file): # forwards binary `IOBase` entries in chunks, while text # file-like data was buffered by `_to_httpx_file` (httpx # rejects text-mode objects in multipart). - timeout=(None if body_is_streamed else upload_timeout), - extensions={REPLAYABLE_BODY_EXTENSION: not body_is_streamed}, + timeout=( + None if multipart_body_is_streamed(files) else upload_timeout + ), ) except httpx.RemoteProtocolError as e: raise await ahandle_envd_api_transport_exception_with_health( diff --git a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py index 6c67d6cce6..d3a2ba6463 100644 --- a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py @@ -5,7 +5,6 @@ from connectrpc.errors import ConnectError from packaging.version import Version -from e2b._retry import REPLAYABLE_BODY_EXTENSION from e2b.api.client_sync import get_envd_api from e2b.connection_config import ( KEEPALIVE_PING_HEADER, @@ -413,7 +412,6 @@ def write_files( if len(httpx_files) == 0: return [] - body_is_streamed = multipart_body_is_streamed(files) try: r = self._envd_api.post( ENVD_API_FILES_ROUTE, @@ -424,8 +422,9 @@ def write_files( # forwards binary `IOBase` entries in chunks, while text # file-like data was buffered by `_to_httpx_file` (httpx # rejects text-mode objects in multipart). - timeout=(None if body_is_streamed else upload_timeout), - extensions={REPLAYABLE_BODY_EXTENSION: not body_is_streamed}, + timeout=( + None if multipart_body_is_streamed(files) else upload_timeout + ), ) except httpx.RemoteProtocolError as e: raise handle_envd_api_transport_exception_with_health(e, self._envd_api) diff --git a/packages/python-sdk/e2b/volume/client_async/__init__.py b/packages/python-sdk/e2b/volume/client_async/__init__.py index ccc7fbc5b1..3aed1272a5 100644 --- a/packages/python-sdk/e2b/volume/client_async/__init__.py +++ b/packages/python-sdk/e2b/volume/client_async/__init__.py @@ -1,6 +1,6 @@ import httpx +from pyqwest.httpx import AsyncPyqwestTransport -from e2b._retry import AsyncRateLimitTransport from e2b.api import ( make_async_logging_event_hooks, proxy_to_config, @@ -26,7 +26,7 @@ def get_streaming_api_client( def _api_client( - config: VolumeConnectionConfig, transport: httpx.AsyncBaseTransport, **kwargs + config: VolumeConnectionConfig, transport: AsyncPyqwestTransport, **kwargs ) -> AsyncVolumeApiClient: if config.access_token is None: raise AuthenticationException( @@ -61,7 +61,7 @@ def _api_client( ) -def get_transport(config: VolumeConnectionConfig) -> httpx.AsyncBaseTransport: +def get_transport(config: VolumeConnectionConfig) -> AsyncPyqwestTransport: """The shared pyqwest-backed httpx transport for volume content API calls — the same pool the control-plane REST API and the envd HTTP API draw from (see :func:`e2b.api.client_async.get_pyqwest_transport`); reqwest pools per @@ -73,13 +73,12 @@ def get_transport(config: VolumeConnectionConfig) -> httpx.AsyncBaseTransport: their whole-request deadlines instead). Streamed downloads, which do need an idle bound, use :func:`get_streaming_transport`. """ - transport = get_httpx_transport(proxy_to_config(config.proxy)) - return AsyncRateLimitTransport(transport, config.retries) + return get_httpx_transport(proxy_to_config(config.proxy)) def get_streaming_transport( config: VolumeConnectionConfig, -) -> httpx.AsyncBaseTransport: +) -> AsyncPyqwestTransport: """The transport for streamed downloads, carrying ``READ_TIMEOUT`` as the idle bound on every read: it resets after each successful read, so it caps how long a streamed download may stall without limiting total transfer @@ -88,5 +87,4 @@ def get_streaming_transport( their own, shared with the sandbox filesystem's streaming transport whenever the two bounds agree. """ - transport = get_httpx_transport(proxy_to_config(config.proxy), READ_TIMEOUT) - return AsyncRateLimitTransport(transport, config.retries) + return get_httpx_transport(proxy_to_config(config.proxy), READ_TIMEOUT) diff --git a/packages/python-sdk/e2b/volume/client_sync/__init__.py b/packages/python-sdk/e2b/volume/client_sync/__init__.py index ddb31652d7..57344abbd2 100644 --- a/packages/python-sdk/e2b/volume/client_sync/__init__.py +++ b/packages/python-sdk/e2b/volume/client_sync/__init__.py @@ -1,6 +1,6 @@ import httpx +from pyqwest.httpx import PyqwestTransport -from e2b._retry import RateLimitTransport from e2b.api import ( make_logging_event_hooks, proxy_to_config, @@ -26,7 +26,7 @@ def get_streaming_api_client( def _api_client( - config: VolumeConnectionConfig, transport: httpx.BaseTransport, **kwargs + config: VolumeConnectionConfig, transport: PyqwestTransport, **kwargs ) -> VolumeApiClient: if config.access_token is None: raise AuthenticationException( @@ -61,7 +61,7 @@ def _api_client( ) -def get_transport(config: VolumeConnectionConfig) -> httpx.BaseTransport: +def get_transport(config: VolumeConnectionConfig) -> PyqwestTransport: """The shared pyqwest-backed httpx transport for volume content API calls — the same pool the control-plane REST API and the envd HTTP API draw from (see :func:`e2b.api.client_sync.get_pyqwest_transport`); reqwest pools per @@ -73,11 +73,10 @@ def get_transport(config: VolumeConnectionConfig) -> httpx.BaseTransport: their whole-request deadlines instead). Streamed downloads, which do need an idle bound, use :func:`get_streaming_transport`. """ - transport = get_httpx_transport(proxy_to_config(config.proxy)) - return RateLimitTransport(transport, config.retries) + return get_httpx_transport(proxy_to_config(config.proxy)) -def get_streaming_transport(config: VolumeConnectionConfig) -> httpx.BaseTransport: +def get_streaming_transport(config: VolumeConnectionConfig) -> PyqwestTransport: """The transport for streamed downloads, carrying ``READ_TIMEOUT`` as the idle bound on every read: it resets after each successful read, so it caps how long a streamed download may stall without limiting total transfer @@ -86,5 +85,4 @@ def get_streaming_transport(config: VolumeConnectionConfig) -> httpx.BaseTranspo their own, shared with the sandbox filesystem's streaming transport whenever the two bounds agree. """ - transport = get_httpx_transport(proxy_to_config(config.proxy), READ_TIMEOUT) - return RateLimitTransport(transport, config.retries) + return get_httpx_transport(proxy_to_config(config.proxy), READ_TIMEOUT) diff --git a/packages/python-sdk/e2b/volume/connection_config.py b/packages/python-sdk/e2b/volume/connection_config.py index adfa1bd5f4..905badebee 100644 --- a/packages/python-sdk/e2b/volume/connection_config.py +++ b/packages/python-sdk/e2b/volume/connection_config.py @@ -5,7 +5,6 @@ from typing_extensions import Unpack -from e2b._retry import resolve_max_retries from e2b.api.metadata import package_version from e2b.connection_config import ProxyTypes @@ -42,10 +41,6 @@ class VolumeApiParams(TypedDict, total=False): request_timeout: Optional[float] """Timeout for the request in **seconds**, defaults to 60 seconds.""" - retries: Optional[int] - """Number of HTTP retries after a 429 with ``Retry-After``. - Defaults to no retries.""" - headers: Optional[Dict[str, str]] """Additional headers to send with the request.""" @@ -103,7 +98,6 @@ def __init__( headers: Optional[Dict[str, str]] = None, proxy: Optional[ProxyTypes] = None, logger: Optional[logging.Logger] = None, - retries: Optional[int] = None, ): self.logger = logger self.domain = domain or self._domain() @@ -117,7 +111,6 @@ def __init__( self.access_token = token self.token = self.access_token self.proxy = proxy - self.retries = resolve_max_retries(retries) self.headers = dict(headers) if headers else {} self.headers["User-Agent"] = f"e2b-python-sdk/{package_version}" @@ -144,7 +137,6 @@ def get_api_params( api_url = opts.get("api_url") proxy = opts.get("proxy") logger = opts.get("logger") - retries = opts.get("retries") req_headers = self.headers.copy() if headers is not None: @@ -160,6 +152,5 @@ def get_api_params( headers=req_headers, proxy=proxy if proxy is not None else self.proxy, logger=logger if logger is not None else self.logger, - retries=retries if retries is not None else self.retries, ) ) diff --git a/packages/python-sdk/e2b/volume/volume_async.py b/packages/python-sdk/e2b/volume/volume_async.py index 209d73b29a..192234f40e 100644 --- a/packages/python-sdk/e2b/volume/volume_async.py +++ b/packages/python-sdk/e2b/volume/volume_async.py @@ -78,7 +78,6 @@ def __init__( domain: Optional[str] = None, debug: Optional[bool] = None, proxy: Optional[ProxyTypes] = None, - retries: Optional[int] = None, ): self._volume_id = volume_id self._name = name @@ -86,7 +85,6 @@ def __init__( self._domain = domain self._debug = debug self._proxy = proxy - self._retries = retries @property def volume_id(self) -> str: @@ -112,11 +110,6 @@ def _get_volume_config( headers=opts.get("headers"), logger=opts.get("logger"), proxy=opts.get("proxy") if opts.get("proxy") is not None else self._proxy, - retries=( - opts.get("retries") - if opts.get("retries") is not None - else self._retries - ), ) @classmethod @@ -157,7 +150,6 @@ async def create(cls, name: str, **opts: Unpack[ApiParams]) -> Self: domain=domain or config.domain, debug=config.debug, proxy=config.proxy, - retries=config.retries, ) return vol @@ -179,7 +171,6 @@ async def connect(cls, volume_id: str, **opts: Unpack[ApiParams]) -> Self: domain=info.domain or config.domain, debug=config.debug, proxy=config.proxy, - retries=config.retries, ) @classmethod diff --git a/packages/python-sdk/e2b/volume/volume_sync.py b/packages/python-sdk/e2b/volume/volume_sync.py index c8f2e592b5..eee5d1b1db 100644 --- a/packages/python-sdk/e2b/volume/volume_sync.py +++ b/packages/python-sdk/e2b/volume/volume_sync.py @@ -77,7 +77,6 @@ def __init__( domain: Optional[str] = None, debug: Optional[bool] = None, proxy: Optional[ProxyTypes] = None, - retries: Optional[int] = None, ): self._volume_id = volume_id self._name = name @@ -85,7 +84,6 @@ def __init__( self._domain = domain self._debug = debug self._proxy = proxy - self._retries = retries @property def volume_id(self) -> str: @@ -111,11 +109,6 @@ def _get_volume_config( headers=opts.get("headers"), logger=opts.get("logger"), proxy=opts.get("proxy") if opts.get("proxy") is not None else self._proxy, - retries=( - opts.get("retries") - if opts.get("retries") is not None - else self._retries - ), ) @classmethod @@ -156,7 +149,6 @@ def create(cls, name: str, **opts: Unpack[ApiParams]) -> Self: domain=domain or config.domain, debug=config.debug, proxy=config.proxy, - retries=config.retries, ) return vol @@ -178,7 +170,6 @@ def connect(cls, volume_id: str, **opts: Unpack[ApiParams]) -> Self: domain=info.domain or config.domain, debug=config.debug, proxy=config.proxy, - retries=config.retries, ) @classmethod diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index 3e1076fd8a..374dc16cda 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -16,6 +16,7 @@ import e2b.api as api import e2b.api.client_async as api_client_async import e2b.api.client_sync as api_client_sync +from e2b._retry import AsyncRateLimitTransport, RateLimitTransport from e2b.api import ( envd_pool_shard, pool_idle_timeout, @@ -35,19 +36,6 @@ from e2b.api.client_sync import get_pyqwest_transport as get_sync_pyqwest_transport from e2b.api.client_sync import get_transport as get_sync_transport from e2b.connection_config import READ_TIMEOUT, ConnectionConfig -from e2b._retry import AsyncRateLimitTransport, RateLimitTransport - - -def unwrap_rate_limit_transport(transport: httpx.BaseTransport) -> httpx.BaseTransport: - assert isinstance(transport, RateLimitTransport) - return transport.transport - - -def unwrap_async_rate_limit_transport( - transport: httpx.AsyncBaseTransport, -) -> httpx.AsyncBaseTransport: - assert isinstance(transport, AsyncRateLimitTransport) - return transport.transport def run_in_worker_thread(fn): @@ -91,12 +79,9 @@ def test_sync_api_client_proxy_uses_explicit_transport(test_api_key): try: assert "proxy" not in api_client._httpx_args - assert unwrap_rate_limit_transport( - httpx_client._transport - ) is unwrap_rate_limit_transport(get_sync_transport(config)) - assert isinstance( - unwrap_rate_limit_transport(httpx_client._transport), PyqwestTransport - ) + assert isinstance(httpx_client._transport, RateLimitTransport) + assert httpx_client._transport.transport is get_sync_transport(config) + assert isinstance(httpx_client._transport.transport, PyqwestTransport) assert httpx_client._mounts == {} finally: httpx_client.close() @@ -111,19 +96,18 @@ def test_sync_retry_policy_wraps_but_does_not_split_cached_transport(test_api_ke ) default_httpx = default_client.get_httpx_client() retrying_httpx = retrying_client.get_httpx_client() - retrying_transport = get_sync_transport( + raw_transport = get_sync_transport( ConnectionConfig(api_key=test_api_key, retries=1) ) try: assert isinstance(default_httpx._transport, RateLimitTransport) - assert default_httpx._transport.retries == 0 + assert default_httpx._transport.retries == 3 assert isinstance(retrying_httpx._transport, RateLimitTransport) assert retrying_httpx._transport.retries == 2 assert retrying_httpx._transport.transport is default_httpx._transport.transport - assert isinstance(retrying_transport, RateLimitTransport) - assert retrying_transport.retries == 1 - assert retrying_transport.transport is default_httpx._transport.transport + assert isinstance(raw_transport, PyqwestTransport) + assert raw_transport is default_httpx._transport.transport finally: retrying_httpx.close() default_httpx.close() @@ -143,28 +127,16 @@ def test_sync_get_transport_keyed_by_proxy(test_api_key): ) try: - proxied_transport = unwrap_rate_limit_transport( - get_sync_transport(proxied_config) - ) - direct_transport = unwrap_rate_limit_transport( - get_sync_transport(direct_config) - ) - other_proxy_transport = unwrap_rate_limit_transport( - get_sync_transport(other_proxy_config) - ) + proxied_transport = get_sync_transport(proxied_config) + direct_transport = get_sync_transport(direct_config) + other_proxy_transport = get_sync_transport(other_proxy_config) assert proxied_transport is not direct_transport assert proxied_transport is not other_proxy_transport assert direct_transport is not other_proxy_transport # The same proxy still reuses the cached instance. - assert ( - unwrap_rate_limit_transport(get_sync_transport(proxied_config)) - is proxied_transport - ) - assert ( - unwrap_rate_limit_transport(get_sync_transport(direct_config)) - is direct_transport - ) + assert get_sync_transport(proxied_config) is proxied_transport + assert get_sync_transport(direct_config) is direct_transport finally: reset_transport_caches() @@ -180,12 +152,10 @@ def test_sync_transports_keyed_by_http_version(test_api_key): ) try: - negotiated = unwrap_rate_limit_transport(get_sync_transport(config)) - http1 = unwrap_rate_limit_transport(get_sync_transport(config, http2=False)) - envd_negotiated = unwrap_rate_limit_transport(get_sync_envd_transport(config)) - envd_http1 = unwrap_rate_limit_transport( - get_sync_envd_transport(config, http2=False) - ) + negotiated = get_sync_transport(config) + http1 = get_sync_transport(config, http2=False) + envd_negotiated = get_sync_envd_transport(config) + envd_http1 = get_sync_envd_transport(config, http2=False) assert http1 is not negotiated assert envd_http1 is not envd_negotiated @@ -195,25 +165,15 @@ def test_sync_transports_keyed_by_http_version(test_api_key): assert envd_http1 is http1 # Each version still has one pool per proxy, and repeat calls with the # same arguments reuse it. - assert unwrap_rate_limit_transport( - get_sync_transport(proxied_config, http2=False) - ) not in ( + assert get_sync_transport(proxied_config, http2=False) not in ( http1, negotiated, ) + assert get_sync_transport(config, http2=False) is http1 + assert get_sync_transport(config) is negotiated + assert get_sync_envd_transport(config, http2=False) is envd_http1 assert ( - unwrap_rate_limit_transport(get_sync_transport(config, http2=False)) - is http1 - ) - assert unwrap_rate_limit_transport(get_sync_transport(config)) is negotiated - assert ( - unwrap_rate_limit_transport(get_sync_envd_transport(config, http2=False)) - is envd_http1 - ) - assert ( - unwrap_rate_limit_transport( - get_sync_envd_transport(config, http2=False, for_streaming=True) - ) + get_sync_envd_transport(config, http2=False, for_streaming=True) is not envd_http1 ) finally: @@ -232,17 +192,13 @@ def test_sync_envd_transports_are_consistently_sharded_by_sandbox( try: assert envd_pool_shard(first) == envd_pool_shard(same_shard) assert envd_pool_shard(first) != envd_pool_shard(different_shard) - assert unwrap_rate_limit_transport( - get_sync_envd_transport(first) - ) is unwrap_rate_limit_transport(get_sync_envd_transport(same_shard)) - assert unwrap_rate_limit_transport( - get_sync_envd_transport(first) - ) is not unwrap_rate_limit_transport(get_sync_envd_transport(different_shard)) + assert get_sync_envd_transport(first) is get_sync_envd_transport(same_shard) + assert get_sync_envd_transport(first) is not get_sync_envd_transport( + different_shard + ) # Generic API traffic remains on shard zero rather than multiplying # control-plane connections for every envd shard. - assert unwrap_rate_limit_transport( - get_sync_envd_transport(first) - ) is not unwrap_rate_limit_transport(get_sync_transport(first)) + assert get_sync_envd_transport(first) is not get_sync_transport(first) finally: reset_transport_caches() @@ -345,19 +301,10 @@ def test_sync_generic_transport_separates_streaming_read_timeout(test_api_key): api_transport = get_sync_transport(config) streaming_transport = get_sync_transport(config, for_streaming=True) - assert isinstance(api_transport, RateLimitTransport) - assert isinstance(api_transport.transport, PyqwestTransport) - assert unwrap_rate_limit_transport( - api_transport - ) is unwrap_rate_limit_transport( - get_sync_transport(config, for_streaming=False) - ) - assert unwrap_rate_limit_transport( - streaming_transport - ) is not unwrap_rate_limit_transport(api_transport) - assert unwrap_rate_limit_transport( - get_sync_transport(config, for_streaming=True) - ) is unwrap_rate_limit_transport(streaming_transport) + assert isinstance(api_transport, PyqwestTransport) + assert api_transport is get_sync_transport(config, for_streaming=False) + assert streaming_transport is not api_transport + assert get_sync_transport(config, for_streaming=True) is streaming_transport finally: reset_transport_caches() @@ -371,12 +318,8 @@ def test_sync_envd_api_client_wiring(test_api_key): try: assert client.base_url == "https://sandbox.e2b.app" - assert unwrap_rate_limit_transport( - client._transport - ) is unwrap_rate_limit_transport(get_sync_transport(config)) - assert unwrap_rate_limit_transport( - streaming._transport - ) is unwrap_rate_limit_transport(get_sync_transport(config, for_streaming=True)) + assert client._transport is get_sync_transport(config) + assert streaming._transport is get_sync_transport(config, for_streaming=True) for header, value in config.sandbox_headers.items(): assert client.headers[header] == value finally: @@ -417,13 +360,9 @@ async def test_async_api_client_proxy_uses_explicit_transport(test_api_key): try: assert "proxy" not in api_client._httpx_args - assert unwrap_async_rate_limit_transport( - httpx_client._transport - ) is unwrap_async_rate_limit_transport(get_async_transport(config)) - assert isinstance( - unwrap_async_rate_limit_transport(httpx_client._transport), - AsyncPyqwestTransport, - ) + assert isinstance(httpx_client._transport, AsyncRateLimitTransport) + assert httpx_client._transport.transport is get_async_transport(config) + assert isinstance(httpx_client._transport.transport, AsyncPyqwestTransport) assert httpx_client._mounts == {} finally: await httpx_client.aclose() @@ -440,23 +379,13 @@ async def test_async_get_transport_keyed_by_proxy(test_api_key): direct_config = ConnectionConfig(api_key=test_api_key) try: - proxied_transport = unwrap_async_rate_limit_transport( - get_async_transport(proxied_config) - ) - direct_transport = unwrap_async_rate_limit_transport( - get_async_transport(direct_config) - ) + proxied_transport = get_async_transport(proxied_config) + direct_transport = get_async_transport(direct_config) assert proxied_transport is not direct_transport # The same proxy still reuses the cached instance. - assert ( - unwrap_async_rate_limit_transport(get_async_transport(proxied_config)) - is proxied_transport - ) - assert ( - unwrap_async_rate_limit_transport(get_async_transport(direct_config)) - is direct_transport - ) + assert get_async_transport(proxied_config) is proxied_transport + assert get_async_transport(direct_config) is direct_transport finally: reset_transport_caches() @@ -467,16 +396,10 @@ async def test_async_transports_keyed_by_http_version(test_api_key): config = ConnectionConfig(api_key=test_api_key) try: - negotiated = unwrap_async_rate_limit_transport(get_async_transport(config)) - http1 = unwrap_async_rate_limit_transport( - get_async_transport(config, http2=False) - ) - envd_negotiated = unwrap_async_rate_limit_transport( - get_async_envd_transport(config) - ) - envd_http1 = unwrap_async_rate_limit_transport( - get_async_envd_transport(config, http2=False) - ) + negotiated = get_async_transport(config) + http1 = get_async_transport(config, http2=False) + envd_negotiated = get_async_envd_transport(config) + envd_http1 = get_async_envd_transport(config, http2=False) assert http1 is not negotiated assert envd_http1 is not envd_negotiated @@ -484,23 +407,11 @@ async def test_async_transports_keyed_by_http_version(test_api_key): # shares the generic transport for each HTTP version. assert envd_negotiated is negotiated assert envd_http1 is http1 + assert get_async_transport(config, http2=False) is http1 + assert get_async_transport(config) is negotiated + assert get_async_envd_transport(config, http2=False) is envd_http1 assert ( - unwrap_async_rate_limit_transport(get_async_transport(config, http2=False)) - is http1 - ) - assert ( - unwrap_async_rate_limit_transport(get_async_transport(config)) is negotiated - ) - assert ( - unwrap_async_rate_limit_transport( - get_async_envd_transport(config, http2=False) - ) - is envd_http1 - ) - assert ( - unwrap_async_rate_limit_transport( - get_async_envd_transport(config, http2=False, for_streaming=True) - ) + get_async_envd_transport(config, http2=False, for_streaming=True) is not envd_http1 ) finally: @@ -518,17 +429,11 @@ async def test_async_envd_transports_are_consistently_sharded_by_sandbox( different_shard = sandbox_config(test_api_key, "sbx-1") try: - assert unwrap_async_rate_limit_transport( - get_async_envd_transport(first) - ) is unwrap_async_rate_limit_transport(get_async_envd_transport(same_shard)) - assert unwrap_async_rate_limit_transport( - get_async_envd_transport(first) - ) is not unwrap_async_rate_limit_transport( - get_async_envd_transport(different_shard) + assert get_async_envd_transport(first) is get_async_envd_transport(same_shard) + assert get_async_envd_transport(first) is not get_async_envd_transport( + different_shard ) - assert unwrap_async_rate_limit_transport( - get_async_envd_transport(first) - ) is not unwrap_async_rate_limit_transport(get_async_transport(first)) + assert get_async_envd_transport(first) is not get_async_transport(first) finally: reset_transport_caches() @@ -619,19 +524,10 @@ async def test_async_generic_transport_separates_streaming_read_timeout(test_api api_transport = get_async_transport(config) streaming_transport = get_async_transport(config, for_streaming=True) - assert isinstance(api_transport, AsyncRateLimitTransport) - assert isinstance(api_transport.transport, AsyncPyqwestTransport) - assert unwrap_async_rate_limit_transport( - api_transport - ) is unwrap_async_rate_limit_transport( - get_async_transport(config, for_streaming=False) - ) - assert unwrap_async_rate_limit_transport( - streaming_transport - ) is not unwrap_async_rate_limit_transport(api_transport) - assert unwrap_async_rate_limit_transport( - get_async_transport(config, for_streaming=True) - ) is unwrap_async_rate_limit_transport(streaming_transport) + assert isinstance(api_transport, AsyncPyqwestTransport) + assert api_transport is get_async_transport(config, for_streaming=False) + assert streaming_transport is not api_transport + assert get_async_transport(config, for_streaming=True) is streaming_transport finally: reset_transport_caches() @@ -645,9 +541,7 @@ async def test_async_envd_api_client_wiring(test_api_key): try: assert client.base_url == "https://sandbox.e2b.app" - assert unwrap_async_rate_limit_transport( - client._transport - ) is unwrap_async_rate_limit_transport(get_async_transport(config)) + assert client._transport is get_async_transport(config) for header, value in config.sandbox_headers.items(): assert client.headers[header] == value finally: @@ -808,7 +702,8 @@ def test_sync_api_client_round_trips_through_pyqwest(test_api_key, echo_server): @pytest.mark.parametrize( - ("retries", "expected_status", "expected_requests"), [(0, 429, 1), (1, 200, 2)] + ("retries", "expected_status", "expected_requests"), + [(None, 200, 2), (0, 429, 1), (1, 200, 2)], ) def test_sync_api_client_retries_rate_limits( test_api_key, echo_server, retries, expected_status, expected_requests @@ -828,11 +723,25 @@ def test_sync_api_client_retries_rate_limits( reset_transport_caches() +def test_sync_envd_api_does_not_retry_rate_limits(test_api_key, echo_server): + reset_transport_caches() + config = ConnectionConfig(api_key=test_api_key, retries=3) + envd_api = get_sync_envd_api(config, echo_server) + + try: + response = envd_api.get("/rate-limit") + assert response.status_code == 429 + assert _EchoHandler.rate_limit_requests == 1 + finally: + envd_api.close() + reset_transport_caches() + + def test_sync_api_client_replays_buffered_body_after_rate_limit( test_api_key, echo_server ): reset_transport_caches() - config = ConnectionConfig(api_key=test_api_key, api_url=echo_server, retries=1) + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) httpx_client = get_sync_api_client(config).get_httpx_client() try: @@ -906,6 +815,38 @@ async def test_async_api_client_round_trips_through_pyqwest(test_api_key, echo_s reset_transport_caches() +@pytest.mark.asyncio +async def test_async_api_client_retries_rate_limits_by_default( + test_api_key, echo_server +): + reset_transport_caches() + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) + httpx_client = get_async_api_client(config).get_async_httpx_client() + + try: + response = await httpx_client.request("GET", "/rate-limit") + assert response.status_code == 200 + assert _EchoHandler.rate_limit_requests == 2 + finally: + await httpx_client.aclose() + reset_transport_caches() + + +@pytest.mark.asyncio +async def test_async_envd_api_does_not_retry_rate_limits(test_api_key, echo_server): + reset_transport_caches() + config = ConnectionConfig(api_key=test_api_key, retries=3) + envd_api = get_async_envd_api(config, echo_server) + + try: + response = await envd_api.get("/rate-limit") + assert response.status_code == 429 + assert _EchoHandler.rate_limit_requests == 1 + finally: + await envd_api.aclose() + reset_transport_caches() + + def test_sync_api_client_leaves_redirects_to_httpx(test_api_key, echo_server): # reqwest would otherwise follow redirects inside the transport, hiding them # from httpx: the generated client asks for no redirect following, so a 302 @@ -1092,15 +1033,15 @@ def test_sync_closing_one_client_leaves_the_shared_pool_open(test_api_key, echo_ # must leave the others — and the pool the envd RPC stack talks to # directly — working. reset_transport_caches() - config = ConnectionConfig(api_key=test_api_key, api_url=echo_server, retries=1) + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) api_httpx = get_sync_api_client(config).get_httpx_client() envd_api = get_sync_envd_api(config, echo_server) pool = get_sync_pyqwest_transport(proxy_to_config(config.proxy)) try: assert isinstance(api_httpx._transport, RateLimitTransport) - assert isinstance(envd_api._transport, RateLimitTransport) - assert api_httpx._transport.transport is envd_api._transport.transport + assert isinstance(envd_api._transport, PyqwestTransport) + assert api_httpx._transport.transport is envd_api._transport assert api_httpx.request("GET", "/sandboxes").status_code == 200 api_httpx.close() @@ -1121,15 +1062,15 @@ async def test_async_closing_one_client_leaves_the_shared_pool_open( test_api_key, echo_server ): reset_transport_caches() - config = ConnectionConfig(api_key=test_api_key, api_url=echo_server, retries=1) + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) api_httpx = get_async_api_client(config).get_async_httpx_client() envd_api = get_async_envd_api(config, echo_server) pool = get_async_pyqwest_transport(proxy_to_config(config.proxy)) try: assert isinstance(api_httpx._transport, AsyncRateLimitTransport) - assert isinstance(envd_api._transport, AsyncRateLimitTransport) - assert api_httpx._transport.transport is envd_api._transport.transport + assert isinstance(envd_api._transport, AsyncPyqwestTransport) + assert api_httpx._transport.transport is envd_api._transport assert (await api_httpx.request("GET", "/sandboxes")).status_code == 200 await api_httpx.aclose() diff --git a/packages/python-sdk/tests/test_connection_config.py b/packages/python-sdk/tests/test_connection_config.py index 67d4c1f8d8..c9afa3a85c 100644 --- a/packages/python-sdk/tests/test_connection_config.py +++ b/packages/python-sdk/tests/test_connection_config.py @@ -234,10 +234,10 @@ def test_get_api_params_includes_sandbox_url(): assert overridden["sandbox_url"] == "https://sandbox.override.com" -def test_retries_default_to_zero_and_propagate(): +def test_retries_default_to_three_and_propagate(): config = ConnectionConfig(retries=5) - assert ConnectionConfig().retries == 0 + assert ConnectionConfig().retries == 3 assert config.retries == 5 assert config.get_api_params()["retries"] == 5 assert config.get_api_params(retries=0)["retries"] == 0 diff --git a/packages/python-sdk/tests/test_rate_limit_retry_transport.py b/packages/python-sdk/tests/test_rate_limit_retry_transport.py index f89aad4d1f..c01108d678 100644 --- a/packages/python-sdk/tests/test_rate_limit_retry_transport.py +++ b/packages/python-sdk/tests/test_rate_limit_retry_transport.py @@ -1,12 +1,7 @@ import httpx import pytest -from e2b._retry import ( - REPLAYABLE_BODY_EXTENSION, - AsyncRateLimitTransport, - RateLimitTransport, - parse_retry_after, -) +from e2b._retry import AsyncRateLimitTransport, RateLimitTransport, parse_retry_after class FakeTransport(httpx.BaseTransport): @@ -83,23 +78,6 @@ def test_retries_rate_limit_after_server_delay_and_replays_body(): assert inner.responses[0].is_closed -def test_retries_buffered_multipart_body(): - inner = FakeTransport([429, 200]) - request = httpx.Request( - "POST", - "https://api.test", - files={"file": ("test.txt", b"payload")}, - extensions={REPLAYABLE_BODY_EXTENSION: True}, - ) - - response = RateLimitTransport( - inner, retries=1, sleep=lambda _: None - ).handle_request(request) - - assert response.status_code == 200 - assert inner.requests[0].content == inner.requests[1].content - - def test_exhaustion_returns_final_rate_limit_response(): inner = FakeTransport([429, 429, 429]) @@ -295,27 +273,6 @@ async def sleep(delay): assert inner.responses[0].is_closed -@pytest.mark.asyncio -async def test_async_retries_buffered_multipart_body(): - inner = FakeAsyncTransport([429, 200]) - request = httpx.Request( - "POST", - "https://api.test", - files={"file": ("test.txt", b"payload")}, - extensions={REPLAYABLE_BODY_EXTENSION: True}, - ) - - async def sleep(_): - pass - - response = await AsyncRateLimitTransport( - inner, retries=1, sleep=sleep - ).handle_async_request(request) - - assert response.status_code == 200 - assert inner.requests[0].content == inner.requests[1].content - - @pytest.mark.asyncio async def test_async_does_not_retry_when_wait_reaches_request_timeout(): inner = FakeAsyncTransport([429], retry_after="3") diff --git a/packages/python-sdk/tests/test_volume_client.py b/packages/python-sdk/tests/test_volume_client.py index e461a1b3f5..8e7c251ca7 100644 --- a/packages/python-sdk/tests/test_volume_client.py +++ b/packages/python-sdk/tests/test_volume_client.py @@ -10,7 +10,6 @@ from transport_caches import reset_transport_caches -from e2b._retry import AsyncRateLimitTransport, RateLimitTransport import e2b.api.client_async as api_client_async import e2b.api.client_sync as api_client_sync import e2b.volume.client_async as client_async @@ -79,64 +78,46 @@ async def run(): asyncio.run(run()) -def test_volume_instances_propagate_and_override_retries(): - volume = Volume("v1", "test", token="vol-token", retries=2) - async_volume = AsyncVolume("v1", "test", token="vol-token", retries=2) - - assert volume._get_volume_config().retries == 2 - assert volume._get_volume_config(retries=1).retries == 1 - assert async_volume._get_volume_config().retries == 2 - assert async_volume._get_volume_config(retries=1).retries == 1 - - -def test_sync_volume_transport_retries_rate_limit(monkeypatch): +def test_sync_volume_transport_does_not_retry_rate_limits(monkeypatch): attempts = 0 def handler(request: httpx.Request) -> httpx.Response: nonlocal attempts attempts += 1 - return httpx.Response( - 429 if attempts == 1 else 200, - headers={"Retry-After": "0"}, - ) + return httpx.Response(429, headers={"Retry-After": "0"}) monkeypatch.setattr( client_sync, "get_httpx_transport", lambda *_args, **_kwargs: httpx.MockTransport(handler), ) - transport = get_sync_transport(VolumeConnectionConfig(token="vol-token", retries=1)) + transport = get_sync_transport(VolumeConnectionConfig(token="vol-token")) with httpx.Client(transport=transport) as client: - assert client.get("https://volume.e2b.test/file").status_code == 200 - assert attempts == 2 + assert client.get("https://volume.e2b.test/file").status_code == 429 + assert attempts == 1 @pytest.mark.asyncio -async def test_async_volume_transport_retries_rate_limit(monkeypatch): +async def test_async_volume_transport_does_not_retry_rate_limits(monkeypatch): attempts = 0 async def handler(request: httpx.Request) -> httpx.Response: nonlocal attempts attempts += 1 - return httpx.Response( - 429 if attempts == 1 else 200, - headers={"Retry-After": "0"}, - ) + return httpx.Response(429, headers={"Retry-After": "0"}) monkeypatch.setattr( client_async, "get_httpx_transport", lambda *_args, **_kwargs: httpx.MockTransport(handler), ) - transport = get_async_transport( - VolumeConnectionConfig(token="vol-token", retries=1) - ) + transport = get_async_transport(VolumeConnectionConfig(token="vol-token")) async with httpx.AsyncClient(transport=transport) as client: response = await client.get("https://volume.e2b.test/file") - assert response.status_code == 200 - assert attempts == 2 + assert response.status_code == 429 + assert attempts == 1 def test_sync_transport_is_cached_per_proxy(): @@ -149,12 +130,9 @@ def test_sync_transport_is_cached_per_proxy(): transport_b = get_sync_transport(config) transport_c = get_sync_transport(proxied) - assert isinstance(transport_a, RateLimitTransport) - assert isinstance(transport_b, RateLimitTransport) - assert isinstance(transport_c, RateLimitTransport) - assert isinstance(transport_a.transport, PyqwestTransport) - assert transport_a.transport is transport_b.transport - assert transport_a.transport is not transport_c.transport + assert isinstance(transport_a, PyqwestTransport) + assert transport_a is transport_b + assert transport_a is not transport_c finally: reset_volume_transports() @@ -170,33 +148,14 @@ def test_volume_transports_are_the_shared_sdk_pools(test_api_key): api_config = ConnectionConfig(api_key=test_api_key) try: - api_transport = api_client_sync.get_transport(api_config) - streaming_api_transport = api_client_sync.get_transport( + assert get_sync_transport(config) is api_client_sync.get_transport(api_config) + assert get_sync_streaming_transport(config) is api_client_sync.get_transport( api_config, for_streaming=True ) - assert isinstance(api_transport, RateLimitTransport) - assert isinstance(streaming_api_transport, RateLimitTransport) - volume_transport = get_sync_transport(config) - streaming_volume_transport = get_sync_streaming_transport(config) - assert isinstance(volume_transport, RateLimitTransport) - assert isinstance(streaming_volume_transport, RateLimitTransport) - assert volume_transport.transport is api_transport.transport - assert streaming_volume_transport.transport is streaming_api_transport.transport - async_api_transport = api_client_async.get_transport(api_config) - streaming_async_api_transport = api_client_async.get_transport( + assert get_async_transport(config) is api_client_async.get_transport(api_config) + assert get_async_streaming_transport(config) is api_client_async.get_transport( api_config, for_streaming=True ) - assert isinstance(async_api_transport, AsyncRateLimitTransport) - assert isinstance(streaming_async_api_transport, AsyncRateLimitTransport) - volume_async_transport = get_async_transport(config) - streaming_volume_async_transport = get_async_streaming_transport(config) - assert isinstance(volume_async_transport, AsyncRateLimitTransport) - assert isinstance(streaming_volume_async_transport, AsyncRateLimitTransport) - assert volume_async_transport.transport is async_api_transport.transport - assert ( - streaming_volume_async_transport.transport - is streaming_async_api_transport.transport - ) finally: reset_volume_transports() @@ -219,9 +178,7 @@ def worker(): thread.start() thread.join() - assert isinstance(main_transport, RateLimitTransport) - assert isinstance(result["transport"], RateLimitTransport) - assert result["transport"].transport is main_transport.transport + assert result["transport"] is main_transport finally: reset_volume_transports() @@ -241,16 +198,12 @@ async def get_transports(): transport_b1, _ = asyncio.run(get_transports()) proxied_transport = get_async_transport(proxied) - assert isinstance(transport_a1, AsyncRateLimitTransport) - assert isinstance(transport_a2, AsyncRateLimitTransport) - assert isinstance(transport_b1, AsyncRateLimitTransport) - assert isinstance(transport_a1.transport, AsyncPyqwestTransport) - assert transport_a1.transport is transport_a2.transport - assert transport_a1.transport is transport_b1.transport + assert isinstance(transport_a1, AsyncPyqwestTransport) + assert transport_a1 is transport_a2 + assert transport_a1 is transport_b1 # Different proxy still gets its own transport. - assert isinstance(proxied_transport, AsyncRateLimitTransport) - assert proxied_transport.transport is not transport_a1.transport + assert proxied_transport is not transport_a1 finally: reset_volume_transports() @@ -423,21 +376,13 @@ def test_stream_transport_is_separate_from_regular_transport(): try: regular = get_sync_transport(config) streaming = get_sync_streaming_transport(config) - assert isinstance(regular, RateLimitTransport) - assert isinstance(streaming, RateLimitTransport) - assert regular.transport is not streaming.transport - next_regular = get_sync_transport(config) - next_streaming = get_sync_streaming_transport(config) - assert isinstance(next_regular, RateLimitTransport) - assert isinstance(next_streaming, RateLimitTransport) - assert next_regular.transport is regular.transport - assert next_streaming.transport is streaming.transport + assert regular is not streaming + assert get_sync_transport(config) is regular + assert get_sync_streaming_transport(config) is streaming async_regular = get_async_transport(config) async_streaming = get_async_streaming_transport(config) - assert isinstance(async_regular, AsyncRateLimitTransport) - assert isinstance(async_streaming, AsyncRateLimitTransport) - assert async_regular.transport is not async_streaming.transport + assert async_regular is not async_streaming finally: reset_volume_transports() diff --git a/packages/python-sdk/tests/test_volume_connection_config.py b/packages/python-sdk/tests/test_volume_connection_config.py index 4a554f63a3..61b54847fe 100644 --- a/packages/python-sdk/tests/test_volume_connection_config.py +++ b/packages/python-sdk/tests/test_volume_connection_config.py @@ -1,5 +1,3 @@ -import pytest - from e2b.volume.connection_config import VolumeConnectionConfig @@ -91,23 +89,3 @@ def test_volume_request_timeout_in_args(): def test_volume_request_timeout_zero_disables_timeout(): config = VolumeConnectionConfig(request_timeout=0) assert config.request_timeout is None - - -def test_volume_retries_default_to_zero(): - assert VolumeConnectionConfig().retries == 0 - - -def test_volume_retries_in_args(): - assert VolumeConnectionConfig(retries=2).retries == 2 - - -def test_volume_retries_are_inherited_and_overridden(): - config = VolumeConnectionConfig(retries=2) - assert config.get_api_params()["retries"] == 2 - assert config.get_api_params(retries=1)["retries"] == 1 - - -@pytest.mark.parametrize("retries", [-1, 1.5, True]) -def test_volume_retries_reject_invalid_values(retries): - with pytest.raises(ValueError, match="non-negative integer"): - VolumeConnectionConfig(retries=retries) From d6d6fe9a30130706ec0453fa4827fda3f63384ab Mon Sep 17 00:00:00 2001 From: nalekseev-e2b Date: Tue, 8 Sep 2026 18:08:35 +0200 Subject: [PATCH 03/16] refactor(sdks): centralize default retry count --- packages/js-sdk/src/connectionConfig.ts | 3 ++- packages/js-sdk/src/retry.ts | 3 +-- packages/js-sdk/tests/connectionConfig.test.ts | 3 ++- packages/js-sdk/tests/retry.test.ts | 5 +++-- packages/python-sdk/e2b/_retry.py | 4 +--- packages/python-sdk/e2b/connection_config.py | 5 ++++- packages/python-sdk/tests/test_connection_config.py | 3 ++- 7 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index 7d91c2a938..1310c8765f 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -7,6 +7,7 @@ import { resolveRetries } from './retry' 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 @@ -443,7 +444,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) + this.retries = resolveRetries(opts?.retries ?? DEFAULT_RETRIES) this.logger = opts?.logger this.requestSource = ConnectionConfig.getRequestSource() this.headers = { ...(opts?.headers ?? {}), ...(opts?.apiHeaders ?? {}) } diff --git a/packages/js-sdk/src/retry.ts b/packages/js-sdk/src/retry.ts index a48d9d7a85..ce695f03e8 100644 --- a/packages/js-sdk/src/retry.ts +++ b/packages/js-sdk/src/retry.ts @@ -1,7 +1,6 @@ const MAX_RETRY_AFTER_SECONDS = 2_147_483 -export function resolveRetries(retries?: number): number { - if (retries === undefined) return 3 +export function resolveRetries(retries: number): number { if (!Number.isInteger(retries) || retries < 0) { throw new Error( `Invalid retries=${retries}: expected a non-negative integer.` diff --git a/packages/js-sdk/tests/connectionConfig.test.ts b/packages/js-sdk/tests/connectionConfig.test.ts index a82a44bd67..2a9a08152f 100644 --- a/packages/js-sdk/tests/connectionConfig.test.ts +++ b/packages/js-sdk/tests/connectionConfig.test.ts @@ -1,6 +1,7 @@ import { assert, test, beforeEach, afterEach } from 'vitest' import { ConnectionConfig, + DEFAULT_RETRIES, setupRequestController, wrapStreamWithConnectionCleanup, } from '../src/connectionConfig' @@ -43,7 +44,7 @@ test('api_url defaults correctly', () => { }) test('retries default to three and accept a non-negative integer', () => { - assert.equal(new ConnectionConfig().retries, 3) + assert.equal(new ConnectionConfig().retries, DEFAULT_RETRIES) assert.equal(new ConnectionConfig({ retries: 2 }).retries, 2) assert.throws(() => new ConnectionConfig({ retries: -1 })) }) diff --git a/packages/js-sdk/tests/retry.test.ts b/packages/js-sdk/tests/retry.test.ts index 1c8567b4fc..c23e3835c8 100644 --- a/packages/js-sdk/tests/retry.test.ts +++ b/packages/js-sdk/tests/retry.test.ts @@ -8,8 +8,9 @@ import { import { EnvdApiClient } from '../src/envd/api' describe('resolveRetries', () => { - test('defaults to three', () => { - expect(resolveRetries()).toBe(3) + test('accepts non-negative integers', () => { + expect(resolveRetries(0)).toBe(0) + expect(resolveRetries(3)).toBe(3) }) test.each([-1, 1.5, Number.NaN])('rejects %s', (retries) => { diff --git a/packages/python-sdk/e2b/_retry.py b/packages/python-sdk/e2b/_retry.py index e58aa51612..3e579ff1c1 100644 --- a/packages/python-sdk/e2b/_retry.py +++ b/packages/python-sdk/e2b/_retry.py @@ -7,9 +7,7 @@ MAX_RETRY_AFTER_SECONDS = 2_147_483_647 -def resolve_max_retries(retries: Optional[int]) -> int: - if retries is None: - return 3 +def resolve_max_retries(retries: int) -> int: if isinstance(retries, bool) or not isinstance(retries, int) or retries < 0: raise ValueError( f"Invalid retries={retries!r}: expected a non-negative integer." diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 8a3bcd08eb..00e1605d27 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -22,6 +22,7 @@ """ REQUEST_TIMEOUT: float = 60.0 # 60 seconds +DEFAULT_RETRIES = 3 # Idle bound for every read on the streaming envd file-transfer transport: # the transfer is aborted when no bytes at all arrive for this long. It @@ -259,7 +260,9 @@ def __init__( self.__extra_sandbox_headers = extra_sandbox_headers or {} self.proxy = proxy - self.retries = resolve_max_retries(retries) + self.retries = resolve_max_retries( + retries if retries is not None else DEFAULT_RETRIES + ) self.request_timeout = ConnectionConfig._get_request_timeout( REQUEST_TIMEOUT, diff --git a/packages/python-sdk/tests/test_connection_config.py b/packages/python-sdk/tests/test_connection_config.py index c9afa3a85c..79f9b1ac1e 100644 --- a/packages/python-sdk/tests/test_connection_config.py +++ b/packages/python-sdk/tests/test_connection_config.py @@ -1,6 +1,7 @@ import pytest from e2b import ConnectionConfig +from e2b.connection_config import DEFAULT_RETRIES def test_api_url_defaults_correctly(monkeypatch): @@ -237,7 +238,7 @@ def test_get_api_params_includes_sandbox_url(): def test_retries_default_to_three_and_propagate(): config = ConnectionConfig(retries=5) - assert ConnectionConfig().retries == 3 + assert ConnectionConfig().retries == DEFAULT_RETRIES assert config.retries == 5 assert config.get_api_params()["retries"] == 5 assert config.get_api_params(retries=0)["retries"] == 0 From 211b42a03ac1b4d1aa713ec1b1f72a029b23ab91 Mon Sep 17 00:00:00 2001 From: nalekseev-e2b Date: Tue, 8 Sep 2026 18:15:50 +0200 Subject: [PATCH 04/16] refactor(python): align retry module naming --- packages/python-sdk/e2b/api/client_async/__init__.py | 2 +- packages/python-sdk/e2b/api/client_sync/__init__.py | 2 +- packages/python-sdk/e2b/connection_config.py | 2 +- packages/python-sdk/e2b/{_retry.py => retry.py} | 0 packages/python-sdk/tests/test_api_client_transport.py | 2 +- packages/python-sdk/tests/test_rate_limit_retry_transport.py | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename packages/python-sdk/e2b/{_retry.py => retry.py} (100%) diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index 2b088412b9..572e38d008 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -7,7 +7,7 @@ from pyqwest.httpx import AsyncPyqwestTransport from pyqwest.middleware.retry import RetryMode, RetryTransport -from e2b._retry import AsyncRateLimitTransport +from e2b.retry import AsyncRateLimitTransport from e2b.api import ( AsyncApiClient, ProxyConfig, diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index 5c1d553116..27eec43f8a 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -7,7 +7,7 @@ from pyqwest.httpx import PyqwestTransport from pyqwest.middleware.retry import RetryMode, SyncRetryTransport -from e2b._retry import RateLimitTransport +from e2b.retry import RateLimitTransport from e2b.api import ( ApiClient, ProxyConfig, diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 00e1605d27..51bc07fe12 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -7,7 +7,7 @@ import httpx from typing_extensions import Unpack -from e2b._retry import resolve_max_retries +from e2b.retry import resolve_max_retries from e2b.api.metadata import package_version from e2b.sandbox_domains import is_supported_sandbox_domain diff --git a/packages/python-sdk/e2b/_retry.py b/packages/python-sdk/e2b/retry.py similarity index 100% rename from packages/python-sdk/e2b/_retry.py rename to packages/python-sdk/e2b/retry.py diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index 374dc16cda..70d8a61142 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -16,7 +16,7 @@ import e2b.api as api import e2b.api.client_async as api_client_async import e2b.api.client_sync as api_client_sync -from e2b._retry import AsyncRateLimitTransport, RateLimitTransport +from e2b.retry import AsyncRateLimitTransport, RateLimitTransport from e2b.api import ( envd_pool_shard, pool_idle_timeout, diff --git a/packages/python-sdk/tests/test_rate_limit_retry_transport.py b/packages/python-sdk/tests/test_rate_limit_retry_transport.py index c01108d678..dfb29f2308 100644 --- a/packages/python-sdk/tests/test_rate_limit_retry_transport.py +++ b/packages/python-sdk/tests/test_rate_limit_retry_transport.py @@ -1,7 +1,7 @@ import httpx import pytest -from e2b._retry import AsyncRateLimitTransport, RateLimitTransport, parse_retry_after +from e2b.retry import AsyncRateLimitTransport, RateLimitTransport, parse_retry_after class FakeTransport(httpx.BaseTransport): From 943e038ced4995e26085088b56e43bee1f3edf1d Mon Sep 17 00:00:00 2001 From: nalekseev-e2b Date: Tue, 8 Sep 2026 18:23:57 +0200 Subject: [PATCH 05/16] fix(sdks): address retry review feedback --- packages/js-sdk/src/connectionConfig.ts | 4 +- packages/js-sdk/src/retry.ts | 4 +- packages/js-sdk/tests/retry.test.ts | 2 + packages/python-sdk/e2b/connection_config.py | 4 +- packages/python-sdk/e2b/retry.py | 12 +++-- .../tests/test_connection_config.py | 3 +- .../tests/test_rate_limit_retry_transport.py | 47 +++++++++++++++++++ 7 files changed, 69 insertions(+), 7 deletions(-) diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index 1310c8765f..7d50e26c37 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -61,7 +61,9 @@ export interface ConnectionOpts { */ requestTimeoutMs?: number /** - * Number of control-plane API retries after a 429 response with `Retry-After`. + * 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. * * @default 3 */ diff --git a/packages/js-sdk/src/retry.ts b/packages/js-sdk/src/retry.ts index ce695f03e8..3d95ecf451 100644 --- a/packages/js-sdk/src/retry.ts +++ b/packages/js-sdk/src/retry.ts @@ -1,8 +1,10 @@ +import { InvalidArgumentError } from './errors' + const MAX_RETRY_AFTER_SECONDS = 2_147_483 export function resolveRetries(retries: number): number { if (!Number.isInteger(retries) || retries < 0) { - throw new Error( + throw new InvalidArgumentError( `Invalid retries=${retries}: expected a non-negative integer.` ) } diff --git a/packages/js-sdk/tests/retry.test.ts b/packages/js-sdk/tests/retry.test.ts index c23e3835c8..03d0f1ad17 100644 --- a/packages/js-sdk/tests/retry.test.ts +++ b/packages/js-sdk/tests/retry.test.ts @@ -6,6 +6,7 @@ import { withRateLimitRetry, } from '../src/retry' import { EnvdApiClient } from '../src/envd/api' +import { InvalidArgumentError } from '../src/errors' describe('resolveRetries', () => { test('accepts non-negative integers', () => { @@ -14,6 +15,7 @@ describe('resolveRetries', () => { }) test.each([-1, 1.5, Number.NaN])('rejects %s', (retries) => { + expect(() => resolveRetries(retries)).toThrow(InvalidArgumentError) expect(() => resolveRetries(retries)).toThrow( 'expected a non-negative integer' ) diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 51bc07fe12..06f0608964 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -51,7 +51,9 @@ class ApiParams(TypedDict, total=False): """Timeout for the request in **seconds**, defaults to 60 seconds.""" retries: Optional[int] - """Number of control-plane HTTP retries after a 429 with ``Retry-After``. + """Number of control-plane HTTP retries after a 429 with a valid, + non-negative integer delta-seconds ``Retry-After`` header. HTTP-date and + malformed values are not retried. A retry is skipped when its wait would exhaust the request timeout. Defaults to 3 retries.""" diff --git a/packages/python-sdk/e2b/retry.py b/packages/python-sdk/e2b/retry.py index 3e579ff1c1..28fa36fd30 100644 --- a/packages/python-sdk/e2b/retry.py +++ b/packages/python-sdk/e2b/retry.py @@ -4,12 +4,14 @@ import httpx +from e2b.exceptions import InvalidArgumentException + MAX_RETRY_AFTER_SECONDS = 2_147_483_647 def resolve_max_retries(retries: int) -> int: if isinstance(retries, bool) or not isinstance(retries, int) or retries < 0: - raise ValueError( + raise InvalidArgumentException( f"Invalid retries={retries!r}: expected a non-negative integer." ) return retries @@ -99,7 +101,9 @@ def handle_request(self, request: httpx.Request) -> httpx.Response: remaining_timeout = deadline - self._monotonic() if remaining_timeout <= 0: raise httpx.TimeoutException( - "Request timeout exhausted while retrying", request=request + "Request timed out while waiting to retry a rate-limited " + "request. Increase `request_timeout` or lower `retries`.", + request=request, ) response = self.transport.handle_request( _copy_request(request, remaining_timeout) @@ -154,7 +158,9 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response: remaining_timeout = deadline - self._monotonic() if remaining_timeout <= 0: raise httpx.TimeoutException( - "Request timeout exhausted while retrying", request=request + "Request timed out while waiting to retry a rate-limited " + "request. Increase `request_timeout` or lower `retries`.", + request=request, ) response = await self.transport.handle_async_request( _copy_request(request, remaining_timeout) diff --git a/packages/python-sdk/tests/test_connection_config.py b/packages/python-sdk/tests/test_connection_config.py index 79f9b1ac1e..4241143ac1 100644 --- a/packages/python-sdk/tests/test_connection_config.py +++ b/packages/python-sdk/tests/test_connection_config.py @@ -2,6 +2,7 @@ from e2b import ConnectionConfig from e2b.connection_config import DEFAULT_RETRIES +from e2b.exceptions import InvalidArgumentException def test_api_url_defaults_correctly(monkeypatch): @@ -246,5 +247,5 @@ def test_retries_default_to_three_and_propagate(): @pytest.mark.parametrize("retries", [-1, 1.5, True]) def test_retries_reject_invalid_values(retries): - with pytest.raises(ValueError): + with pytest.raises(InvalidArgumentException): ConnectionConfig(retries=retries) diff --git a/packages/python-sdk/tests/test_rate_limit_retry_transport.py b/packages/python-sdk/tests/test_rate_limit_retry_transport.py index dfb29f2308..7eecca90e4 100644 --- a/packages/python-sdk/tests/test_rate_limit_retry_transport.py +++ b/packages/python-sdk/tests/test_rate_limit_retry_transport.py @@ -166,6 +166,29 @@ def test_does_not_retry_when_cumulative_wait_reaches_request_timeout(): assert len(inner.requests) == 2 +def test_timeout_after_retry_wait_names_configuration_options(): + inner = FakeTransport([429]) + clock = FakeClock() + + def sleep(delay): + clock.sleeps.append(delay) + clock.now = 4.0 + + request = httpx.Request( + "GET", + "https://api.test", + extensions={"timeout": {"read": 3.0}}, + ) + + with pytest.raises(httpx.TimeoutException, match=r"request_timeout.*retries"): + RateLimitTransport( + inner, + retries=1, + sleep=sleep, + monotonic=clock.monotonic, + ).handle_request(request) + + def test_retry_uses_only_the_remaining_request_timeout(): clock = FakeClock() @@ -296,6 +319,30 @@ async def sleep(delay): assert len(inner.requests) == 1 +@pytest.mark.asyncio +async def test_async_timeout_after_retry_wait_names_configuration_options(): + inner = FakeAsyncTransport([429]) + clock = FakeClock() + + async def sleep(delay): + clock.sleeps.append(delay) + clock.now = 4.0 + + request = httpx.Request( + "GET", + "https://api.test", + extensions={"timeout": {"read": 3.0}}, + ) + + with pytest.raises(httpx.TimeoutException, match=r"request_timeout.*retries"): + await AsyncRateLimitTransport( + inner, + retries=1, + sleep=sleep, + monotonic=clock.monotonic, + ).handle_async_request(request) + + @pytest.mark.asyncio async def test_async_429_without_retry_after_is_propagated_as_is(): inner = FakeAsyncTransport([429], retry_after=None) From bc53da69fcd923a4aaefcb6278c2eeece3cc2549 Mon Sep 17 00:00:00 2001 From: nalekseev-e2b Date: Tue, 8 Sep 2026 18:27:54 +0200 Subject: [PATCH 06/16] fix(python): preserve retry phase timeouts --- packages/python-sdk/e2b/retry.py | 10 ---------- .../tests/test_rate_limit_retry_transport.py | 20 +++++++++++++------ 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/python-sdk/e2b/retry.py b/packages/python-sdk/e2b/retry.py index 28fa36fd30..d03f43a5cf 100644 --- a/packages/python-sdk/e2b/retry.py +++ b/packages/python-sdk/e2b/retry.py @@ -38,16 +38,6 @@ def _copy_request( key: remaining_timeout if value is None else min(value, remaining_timeout) for key, value in timeout.items() } - operation_phases = [ - key for key in ("read", "write") if adjusted_timeout.get(key) is not None - ] - operation_timeout = sum( - max(adjusted_timeout[key], 0.0) for key in operation_phases - ) - if operation_timeout > remaining_timeout: - scale = remaining_timeout / operation_timeout - for key in operation_phases: - adjusted_timeout[key] = max(adjusted_timeout[key], 0.0) * scale extensions["timeout"] = adjusted_timeout return httpx.Request( diff --git a/packages/python-sdk/tests/test_rate_limit_retry_transport.py b/packages/python-sdk/tests/test_rate_limit_retry_transport.py index 7eecca90e4..d09ed2beea 100644 --- a/packages/python-sdk/tests/test_rate_limit_retry_transport.py +++ b/packages/python-sdk/tests/test_rate_limit_retry_transport.py @@ -217,7 +217,7 @@ def handle_request(self, request): } -def test_retry_timeout_phases_fit_within_remaining_budget(): +def test_retry_preserves_remaining_budget_for_each_timeout_phase(): clock = FakeClock() class AdvancingTransport(FakeTransport): @@ -240,8 +240,12 @@ def handle_request(self, request): inner, retries=1, sleep=clock.sleep, monotonic=clock.monotonic ).handle_request(request) - timeout = inner.requests[1].extensions["timeout"] - assert timeout["read"] + timeout["write"] == pytest.approx(1.0) + assert inner.requests[1].extensions["timeout"] == { + "connect": 1.0, + "read": 1.0, + "write": 1.0, + "pool": 1.0, + } def test_zero_retries_passes_the_original_request_through(): @@ -412,7 +416,7 @@ async def sleep(delay): @pytest.mark.asyncio -async def test_async_retry_timeout_phases_fit_within_remaining_budget(): +async def test_async_retry_preserves_remaining_budget_for_each_timeout_phase(): clock = FakeClock() class AdvancingTransport(FakeAsyncTransport): @@ -444,8 +448,12 @@ async def sleep(delay): ) ) - timeout = inner.requests[1].extensions["timeout"] - assert timeout["read"] + timeout["write"] == pytest.approx(1.0) + assert inner.requests[1].extensions["timeout"] == { + "connect": 1.0, + "read": 1.0, + "write": 1.0, + "pool": 1.0, + } @pytest.mark.asyncio From af18843d88752009a613cdeeb689aff4d351992d Mon Sep 17 00:00:00 2001 From: nalekseev-e2b Date: Tue, 8 Sep 2026 18:59:17 +0200 Subject: [PATCH 07/16] fix(sdks): bound retries without request timeout --- packages/js-sdk/src/connectionConfig.ts | 1 + packages/js-sdk/src/retry.ts | 8 +-- packages/js-sdk/tests/retry.test.ts | 21 ++++++++ packages/python-sdk/e2b/connection_config.py | 1 + packages/python-sdk/e2b/retry.py | 23 ++++----- .../tests/test_rate_limit_retry_transport.py | 50 +++++++++++++++++++ 6 files changed, 87 insertions(+), 17 deletions(-) diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index 7d50e26c37..a9ef7fdf84 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -64,6 +64,7 @@ export interface ConnectionOpts { * 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 */ diff --git a/packages/js-sdk/src/retry.ts b/packages/js-sdk/src/retry.ts index 3d95ecf451..dcf0f328bf 100644 --- a/packages/js-sdk/src/retry.ts +++ b/packages/js-sdk/src/retry.ts @@ -1,6 +1,7 @@ import { InvalidArgumentError } from './errors' 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) { @@ -60,9 +61,8 @@ export function withRateLimitRetry( if (retries === 0) return fetchImpl(input, init) const request = new Request(input as RequestInfo, init) - const deadline = requestTimeoutMs - ? monotonic() + requestTimeoutMs - : undefined + const deadline = + monotonic() + (requestTimeoutMs || MAX_RETRY_WAIT_WITHOUT_TIMEOUT_MS) for (let attempt = 0; ; attempt++) { const response = await fetchImpl(request.clone()) @@ -73,7 +73,7 @@ export function withRateLimitRetry( response.status !== 429 || delayMs === undefined || attempt === retries || - (deadline !== undefined && monotonic() + delayMs >= deadline) + monotonic() + delayMs >= deadline ) { return response } diff --git a/packages/js-sdk/tests/retry.test.ts b/packages/js-sdk/tests/retry.test.ts index 03d0f1ad17..45548bb060 100644 --- a/packages/js-sdk/tests/retry.test.ts +++ b/packages/js-sdk/tests/retry.test.ts @@ -103,6 +103,27 @@ test('propagates 429 when Retry-After exceeds the request timeout', async () => expect(sleep).not.toHaveBeenCalled() }) +test('bounds retry waits when the request timeout is disabled', async () => { + const fetchImpl = vi.fn( + async () => + new Response(null, { + status: 429, + headers: { 'Retry-After': '60' }, + }) + ) as typeof fetch + const sleep = vi.fn(async () => {}) + const fetchWithRetry = withRateLimitRetry(fetchImpl, 3, 0, { + monotonic: () => 0, + sleep, + }) + + const response = await fetchWithRetry('https://api.e2b.test/resource') + + expect(response.status).toBe(429) + expect(fetchImpl).toHaveBeenCalledOnce() + expect(sleep).not.toHaveBeenCalled() +}) + test('propagates 429 without Retry-After as is', async () => { const rateLimited = new Response('rate limited', { status: 429 }) const fetchImpl = vi.fn(async () => rateLimited) as typeof fetch diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 06f0608964..23878f9786 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -55,6 +55,7 @@ class ApiParams(TypedDict, total=False): non-negative integer delta-seconds ``Retry-After`` header. HTTP-date and malformed values are not retried. A retry is skipped when its wait would exhaust the request timeout. + Retry waits use a 60-second total limit when request timeouts are disabled. Defaults to 3 retries.""" headers: Optional[Dict[str, str]] diff --git a/packages/python-sdk/e2b/retry.py b/packages/python-sdk/e2b/retry.py index d03f43a5cf..04478a30d4 100644 --- a/packages/python-sdk/e2b/retry.py +++ b/packages/python-sdk/e2b/retry.py @@ -7,6 +7,7 @@ from e2b.exceptions import InvalidArgumentException MAX_RETRY_AFTER_SECONDS = 2_147_483_647 +MAX_RETRY_WAIT_WITHOUT_TIMEOUT_SECONDS = 60.0 def resolve_max_retries(retries: int) -> int: @@ -49,17 +50,17 @@ def _copy_request( ) -def _request_deadline( - request: httpx.Request, monotonic: Callable[[], float] -) -> Optional[float]: +def _request_deadline(request: httpx.Request, monotonic: Callable[[], float]) -> float: timeout = request.extensions.get("timeout") if not isinstance(timeout, dict): - return None + return monotonic() + MAX_RETRY_WAIT_WITHOUT_TIMEOUT_SECONDS values = [value for value in timeout.values() if value is not None] # A monotonic clock cannot jump when the system wall clock is adjusted, # keeping elapsed timeout calculations stable across retries. - return monotonic() + min(values) if values else None + return monotonic() + ( + min(values) if values else MAX_RETRY_WAIT_WITHOUT_TIMEOUT_SECONDS + ) class RateLimitTransport(httpx.BaseTransport): @@ -87,7 +88,7 @@ def handle_request(self, request: httpx.Request) -> httpx.Response: deadline = _request_deadline(request, self._monotonic) for attempt in range(self.retries + 1): remaining_timeout = None - if attempt > 0 and deadline is not None: + if attempt > 0: remaining_timeout = deadline - self._monotonic() if remaining_timeout <= 0: raise httpx.TimeoutException( @@ -103,9 +104,7 @@ def handle_request(self, request: httpx.Request) -> httpx.Response: response.status_code != 429 or retry_after is None or attempt == self.retries - or ( - deadline is not None and self._monotonic() + retry_after >= deadline - ) + or (self._monotonic() + retry_after >= deadline) ): return response @@ -144,7 +143,7 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response: deadline = _request_deadline(request, self._monotonic) for attempt in range(self.retries + 1): remaining_timeout = None - if attempt > 0 and deadline is not None: + if attempt > 0: remaining_timeout = deadline - self._monotonic() if remaining_timeout <= 0: raise httpx.TimeoutException( @@ -160,9 +159,7 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response: response.status_code != 429 or retry_after is None or attempt == self.retries - or ( - deadline is not None and self._monotonic() + retry_after >= deadline - ) + or (self._monotonic() + retry_after >= deadline) ): return response diff --git a/packages/python-sdk/tests/test_rate_limit_retry_transport.py b/packages/python-sdk/tests/test_rate_limit_retry_transport.py index d09ed2beea..d7dcd5c079 100644 --- a/packages/python-sdk/tests/test_rate_limit_retry_transport.py +++ b/packages/python-sdk/tests/test_rate_limit_retry_transport.py @@ -132,6 +132,26 @@ def test_retry_after_exceeding_timeout_is_propagated_as_is(): assert sleeps == [] +def test_retry_wait_is_bounded_when_request_timeout_is_disabled(): + inner = FakeTransport([429], retry_after="60") + sleeps = [] + request = httpx.Request( + "GET", + "https://api.test", + extensions={ + "timeout": {"connect": None, "read": None, "write": None, "pool": None} + }, + ) + + response = RateLimitTransport( + inner, retries=3, sleep=sleeps.append, monotonic=lambda: 0.0 + ).handle_request(request) + + assert response is inner.responses[0] + assert len(inner.requests) == 1 + assert sleeps == [] + + def test_does_not_retry_other_status_codes(): inner = FakeTransport([503], retry_after="0") @@ -382,6 +402,36 @@ async def sleep(delay): assert sleeps == [] +@pytest.mark.asyncio +async def test_async_retry_wait_is_bounded_when_request_timeout_is_disabled(): + inner = FakeAsyncTransport([429], retry_after="60") + sleeps = [] + + async def sleep(delay): + sleeps.append(delay) + + response = await AsyncRateLimitTransport( + inner, retries=3, sleep=sleep, monotonic=lambda: 0.0 + ).handle_async_request( + httpx.Request( + "GET", + "https://api.test", + extensions={ + "timeout": { + "connect": None, + "read": None, + "write": None, + "pool": None, + } + }, + ) + ) + + assert response is inner.responses[0] + assert len(inner.requests) == 1 + assert sleeps == [] + + @pytest.mark.asyncio async def test_async_retry_uses_only_the_remaining_request_timeout(): clock = FakeClock() From 6ee54bfd617653f120b0e37013d039db3143fdcc Mon Sep 17 00:00:00 2001 From: nalekseev-e2b Date: Wed, 9 Sep 2026 15:37:41 +0200 Subject: [PATCH 08/16] refactor(python): centralize retry transport wrapping --- .../e2b/api/client_async/__init__.py | 29 ++- .../e2b/api/client_sync/__init__.py | 29 ++- packages/python-sdk/e2b/retry.py | 6 +- .../tests/test_api_client_transport.py | 198 ++++++++++++------ .../tests/test_rate_limit_retry_transport.py | 50 ++--- .../python-sdk/tests/test_volume_client.py | 20 +- 6 files changed, 208 insertions(+), 124 deletions(-) diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index 572e38d008..7e6d20ef53 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -7,7 +7,7 @@ from pyqwest.httpx import AsyncPyqwestTransport from pyqwest.middleware.retry import RetryMode, RetryTransport -from e2b.retry import AsyncRateLimitTransport +from e2b.retry import AsyncRetryableTransport, resolve_max_retries from e2b.api import ( AsyncApiClient, ProxyConfig, @@ -24,7 +24,7 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient: return AsyncApiClient( config, - transport=AsyncRateLimitTransport(get_transport(config), config.retries), + transport=get_transport(config), **kwargs, ) @@ -157,7 +157,8 @@ def get_transport( *, for_streaming: bool = False, pool_shard: int = 0, -) -> AsyncPyqwestTransport: + retries: Optional[int] = None, +) -> AsyncRetryableTransport: """The shared httpx transport factory for the control-plane REST API and envd HTTP API (file transfers, health checks). Generic callers use shard zero; :func:`get_envd_transport` supplies a sandbox-specific shard. For TLS @@ -171,6 +172,11 @@ def get_transport( one-connection-per-request closes the connection and the server observes the disconnect. + The shared pool is wrapped with the configured control-plane rate-limit + retry policy. Internal callers that must not retry HTTP responses pass + ``retries=0``; connection-establishment retries remain active in the pool + below this wrapper. + ``for_streaming`` selects the pool carrying ``READ_TIMEOUT``, the idle bound on every read: it resets after each successful read, so it caps how long a streamed download may stall without limiting total transfer time. @@ -179,17 +185,16 @@ def get_transport( downloads take it, and they get their own pool (see :func:`get_pyqwest_transport`). """ - return get_httpx_transport( - proxy_to_config(config.proxy), - READ_TIMEOUT if for_streaming else None, - http2, - pool_shard, - ) + proxy = proxy_to_config(config.proxy) + read_timeout = READ_TIMEOUT if for_streaming else None + max_retries = config.retries if retries is None else resolve_max_retries(retries) + transport = get_httpx_transport(proxy, read_timeout, http2, pool_shard) + return AsyncRetryableTransport(transport, max_retries) def get_envd_transport( config: ConnectionConfig, http2: bool = True, *, for_streaming: bool = False -) -> AsyncPyqwestTransport: +) -> AsyncRetryableTransport: """The envd HTTP API's transport, sharded by sandbox ID. Envd RPC and non-streaming HTTP traffic for one sandbox resolve the same @@ -199,13 +204,15 @@ def get_envd_transport( read-timeout-keyed pool. Kept as a separate factory because generic API transports stay on shard - zero while envd transports use the sandbox's shard. + zero while envd transports use the sandbox's shard. HTTP response retries + are disabled for envd; the underlying pool still retries failed connects. """ return get_transport( config, http2, for_streaming=for_streaming, pool_shard=envd_pool_shard(config), + retries=0, ) diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index 27eec43f8a..1370227318 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -7,7 +7,7 @@ from pyqwest.httpx import PyqwestTransport from pyqwest.middleware.retry import RetryMode, SyncRetryTransport -from e2b.retry import RateLimitTransport +from e2b.retry import RetryableTransport, resolve_max_retries from e2b.api import ( ApiClient, ProxyConfig, @@ -24,7 +24,7 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient: return ApiClient( config, - transport=RateLimitTransport(get_transport(config), config.retries), + transport=get_transport(config), **kwargs, ) @@ -156,7 +156,8 @@ def get_transport( *, for_streaming: bool = False, pool_shard: int = 0, -) -> PyqwestTransport: + retries: Optional[int] = None, +) -> RetryableTransport: """The shared httpx transport factory for the control-plane REST API and envd HTTP API (file transfers, health checks). Generic callers use shard zero; :func:`get_envd_transport` supplies a sandbox-specific shard. For TLS @@ -170,6 +171,11 @@ def get_transport( one-connection-per-request closes the connection and the server observes the disconnect. + The shared pool is wrapped with the configured control-plane rate-limit + retry policy. Internal callers that must not retry HTTP responses pass + ``retries=0``; connection-establishment retries remain active in the pool + below this wrapper. + ``for_streaming`` selects the pool carrying ``READ_TIMEOUT``, the idle bound on every read: it resets after each successful read, so it caps how long a streamed download may stall without limiting total transfer time. @@ -178,17 +184,16 @@ def get_transport( downloads take it, and they get their own pool (see :func:`get_pyqwest_transport`). """ - return get_httpx_transport( - proxy_to_config(config.proxy), - READ_TIMEOUT if for_streaming else None, - http2, - pool_shard, - ) + proxy = proxy_to_config(config.proxy) + read_timeout = READ_TIMEOUT if for_streaming else None + max_retries = config.retries if retries is None else resolve_max_retries(retries) + transport = get_httpx_transport(proxy, read_timeout, http2, pool_shard) + return RetryableTransport(transport, max_retries) def get_envd_transport( config: ConnectionConfig, http2: bool = True, *, for_streaming: bool = False -) -> PyqwestTransport: +) -> RetryableTransport: """The envd HTTP API's transport, sharded by sandbox ID. Envd RPC and non-streaming HTTP traffic for one sandbox resolve the same @@ -198,13 +203,15 @@ def get_envd_transport( read-timeout-keyed pool. Kept as a separate factory because generic API transports stay on shard - zero while envd transports use the sandbox's shard. + zero while envd transports use the sandbox's shard. HTTP response retries + are disabled for envd; the underlying pool still retries failed connects. """ return get_transport( config, http2, for_streaming=for_streaming, pool_shard=envd_pool_shard(config), + retries=0, ) diff --git a/packages/python-sdk/e2b/retry.py b/packages/python-sdk/e2b/retry.py index 04478a30d4..6761aa2c2f 100644 --- a/packages/python-sdk/e2b/retry.py +++ b/packages/python-sdk/e2b/retry.py @@ -63,7 +63,7 @@ def _request_deadline(request: httpx.Request, monotonic: Callable[[], float]) -> ) -class RateLimitTransport(httpx.BaseTransport): +class RetryableTransport(httpx.BaseTransport): """Retry replayable requests after a 429 carrying ``Retry-After``.""" def __init__( @@ -118,8 +118,8 @@ def close(self) -> None: pass -class AsyncRateLimitTransport(httpx.AsyncBaseTransport): - """Async counterpart of :class:`RateLimitTransport`.""" +class AsyncRetryableTransport(httpx.AsyncBaseTransport): + """Async counterpart of :class:`RetryableTransport`.""" def __init__( self, diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index 70d8a61142..186653f427 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -16,7 +16,7 @@ import e2b.api as api import e2b.api.client_async as api_client_async import e2b.api.client_sync as api_client_sync -from e2b.retry import AsyncRateLimitTransport, RateLimitTransport +from e2b.retry import AsyncRetryableTransport, RetryableTransport from e2b.api import ( envd_pool_shard, pool_idle_timeout, @@ -79,8 +79,8 @@ def test_sync_api_client_proxy_uses_explicit_transport(test_api_key): try: assert "proxy" not in api_client._httpx_args - assert isinstance(httpx_client._transport, RateLimitTransport) - assert httpx_client._transport.transport is get_sync_transport(config) + assert isinstance(httpx_client._transport, RetryableTransport) + assert httpx_client._transport.transport is get_sync_transport(config).transport assert isinstance(httpx_client._transport.transport, PyqwestTransport) assert httpx_client._mounts == {} finally: @@ -96,18 +96,18 @@ def test_sync_retry_policy_wraps_but_does_not_split_cached_transport(test_api_ke ) default_httpx = default_client.get_httpx_client() retrying_httpx = retrying_client.get_httpx_client() - raw_transport = get_sync_transport( + other_retry_policy = get_sync_transport( ConnectionConfig(api_key=test_api_key, retries=1) ) try: - assert isinstance(default_httpx._transport, RateLimitTransport) + assert isinstance(default_httpx._transport, RetryableTransport) assert default_httpx._transport.retries == 3 - assert isinstance(retrying_httpx._transport, RateLimitTransport) + assert isinstance(retrying_httpx._transport, RetryableTransport) assert retrying_httpx._transport.retries == 2 assert retrying_httpx._transport.transport is default_httpx._transport.transport - assert isinstance(raw_transport, PyqwestTransport) - assert raw_transport is default_httpx._transport.transport + assert other_retry_policy.retries == 1 + assert other_retry_policy.transport is default_httpx._transport.transport finally: retrying_httpx.close() default_httpx.close() @@ -131,12 +131,14 @@ def test_sync_get_transport_keyed_by_proxy(test_api_key): direct_transport = get_sync_transport(direct_config) other_proxy_transport = get_sync_transport(other_proxy_config) - assert proxied_transport is not direct_transport - assert proxied_transport is not other_proxy_transport - assert direct_transport is not other_proxy_transport + assert proxied_transport.transport is not direct_transport.transport + assert proxied_transport.transport is not other_proxy_transport.transport + assert direct_transport.transport is not other_proxy_transport.transport # The same proxy still reuses the cached instance. - assert get_sync_transport(proxied_config) is proxied_transport - assert get_sync_transport(direct_config) is direct_transport + assert ( + get_sync_transport(proxied_config).transport is proxied_transport.transport + ) + assert get_sync_transport(direct_config).transport is direct_transport.transport finally: reset_transport_caches() @@ -157,24 +159,29 @@ def test_sync_transports_keyed_by_http_version(test_api_key): envd_negotiated = get_sync_envd_transport(config) envd_http1 = get_sync_envd_transport(config, http2=False) - assert http1 is not negotiated - assert envd_http1 is not envd_negotiated + assert http1.transport is not negotiated.transport + assert envd_http1.transport is not envd_negotiated.transport # A config without sandbox headers resolves envd to shard zero, so it - # shares the generic transport for each HTTP version. - assert envd_negotiated is negotiated - assert envd_http1 is http1 + # shares the generic transport's pool for each HTTP version while using + # its own wrapper with response retries disabled. + assert envd_negotiated.retries == 0 + assert envd_negotiated.transport is negotiated.transport + assert envd_http1.transport is http1.transport # Each version still has one pool per proxy, and repeat calls with the # same arguments reuse it. - assert get_sync_transport(proxied_config, http2=False) not in ( - http1, - negotiated, + assert get_sync_transport(proxied_config, http2=False).transport not in ( + http1.transport, + negotiated.transport, ) - assert get_sync_transport(config, http2=False) is http1 - assert get_sync_transport(config) is negotiated - assert get_sync_envd_transport(config, http2=False) is envd_http1 + assert get_sync_transport(config, http2=False).transport is http1.transport + assert get_sync_transport(config).transport is negotiated.transport assert ( - get_sync_envd_transport(config, http2=False, for_streaming=True) - is not envd_http1 + get_sync_envd_transport(config, http2=False).transport + is envd_http1.transport + ) + assert ( + get_sync_envd_transport(config, http2=False, for_streaming=True).transport + is not envd_http1.transport ) finally: reset_transport_caches() @@ -192,13 +199,20 @@ def test_sync_envd_transports_are_consistently_sharded_by_sandbox( try: assert envd_pool_shard(first) == envd_pool_shard(same_shard) assert envd_pool_shard(first) != envd_pool_shard(different_shard) - assert get_sync_envd_transport(first) is get_sync_envd_transport(same_shard) - assert get_sync_envd_transport(first) is not get_sync_envd_transport( - different_shard + assert ( + get_sync_envd_transport(first).transport + is get_sync_envd_transport(same_shard).transport + ) + assert ( + get_sync_envd_transport(first).transport + is not get_sync_envd_transport(different_shard).transport ) # Generic API traffic remains on shard zero rather than multiplying # control-plane connections for every envd shard. - assert get_sync_envd_transport(first) is not get_sync_transport(first) + assert ( + get_sync_envd_transport(first).transport + is not get_sync_transport(first).transport + ) finally: reset_transport_caches() @@ -301,10 +315,17 @@ def test_sync_generic_transport_separates_streaming_read_timeout(test_api_key): api_transport = get_sync_transport(config) streaming_transport = get_sync_transport(config, for_streaming=True) - assert isinstance(api_transport, PyqwestTransport) - assert api_transport is get_sync_transport(config, for_streaming=False) - assert streaming_transport is not api_transport - assert get_sync_transport(config, for_streaming=True) is streaming_transport + assert isinstance(api_transport, RetryableTransport) + assert isinstance(api_transport.transport, PyqwestTransport) + assert ( + api_transport.transport + is get_sync_transport(config, for_streaming=False).transport + ) + assert streaming_transport.transport is not api_transport.transport + assert ( + get_sync_transport(config, for_streaming=True).transport + is streaming_transport.transport + ) finally: reset_transport_caches() @@ -318,8 +339,17 @@ def test_sync_envd_api_client_wiring(test_api_key): try: assert client.base_url == "https://sandbox.e2b.app" - assert client._transport is get_sync_transport(config) - assert streaming._transport is get_sync_transport(config, for_streaming=True) + assert isinstance(client._transport, RetryableTransport) + assert isinstance(streaming._transport, RetryableTransport) + assert ( + client._transport.transport + is get_sync_transport(config, retries=0).transport + ) + assert client._transport.retries == 0 + assert ( + streaming._transport.transport + is get_sync_transport(config, for_streaming=True, retries=0).transport + ) for header, value in config.sandbox_headers.items(): assert client.headers[header] == value finally: @@ -360,8 +390,10 @@ async def test_async_api_client_proxy_uses_explicit_transport(test_api_key): try: assert "proxy" not in api_client._httpx_args - assert isinstance(httpx_client._transport, AsyncRateLimitTransport) - assert httpx_client._transport.transport is get_async_transport(config) + assert isinstance(httpx_client._transport, AsyncRetryableTransport) + assert ( + httpx_client._transport.transport is get_async_transport(config).transport + ) assert isinstance(httpx_client._transport.transport, AsyncPyqwestTransport) assert httpx_client._mounts == {} finally: @@ -382,10 +414,14 @@ async def test_async_get_transport_keyed_by_proxy(test_api_key): proxied_transport = get_async_transport(proxied_config) direct_transport = get_async_transport(direct_config) - assert proxied_transport is not direct_transport + assert proxied_transport.transport is not direct_transport.transport # The same proxy still reuses the cached instance. - assert get_async_transport(proxied_config) is proxied_transport - assert get_async_transport(direct_config) is direct_transport + assert ( + get_async_transport(proxied_config).transport is proxied_transport.transport + ) + assert ( + get_async_transport(direct_config).transport is direct_transport.transport + ) finally: reset_transport_caches() @@ -401,18 +437,23 @@ async def test_async_transports_keyed_by_http_version(test_api_key): envd_negotiated = get_async_envd_transport(config) envd_http1 = get_async_envd_transport(config, http2=False) - assert http1 is not negotiated - assert envd_http1 is not envd_negotiated + assert http1.transport is not negotiated.transport + assert envd_http1.transport is not envd_negotiated.transport # A config without sandbox headers resolves envd to shard zero, so it - # shares the generic transport for each HTTP version. - assert envd_negotiated is negotiated - assert envd_http1 is http1 - assert get_async_transport(config, http2=False) is http1 - assert get_async_transport(config) is negotiated - assert get_async_envd_transport(config, http2=False) is envd_http1 + # shares the generic transport's pool for each HTTP version while using + # its own wrapper with response retries disabled. + assert envd_negotiated.retries == 0 + assert envd_negotiated.transport is negotiated.transport + assert envd_http1.transport is http1.transport + assert get_async_transport(config, http2=False).transport is http1.transport + assert get_async_transport(config).transport is negotiated.transport assert ( - get_async_envd_transport(config, http2=False, for_streaming=True) - is not envd_http1 + get_async_envd_transport(config, http2=False).transport + is envd_http1.transport + ) + assert ( + get_async_envd_transport(config, http2=False, for_streaming=True).transport + is not envd_http1.transport ) finally: reset_transport_caches() @@ -429,11 +470,18 @@ async def test_async_envd_transports_are_consistently_sharded_by_sandbox( different_shard = sandbox_config(test_api_key, "sbx-1") try: - assert get_async_envd_transport(first) is get_async_envd_transport(same_shard) - assert get_async_envd_transport(first) is not get_async_envd_transport( - different_shard + assert ( + get_async_envd_transport(first).transport + is get_async_envd_transport(same_shard).transport + ) + assert ( + get_async_envd_transport(first).transport + is not get_async_envd_transport(different_shard).transport + ) + assert ( + get_async_envd_transport(first).transport + is not get_async_transport(first).transport ) - assert get_async_envd_transport(first) is not get_async_transport(first) finally: reset_transport_caches() @@ -524,10 +572,17 @@ async def test_async_generic_transport_separates_streaming_read_timeout(test_api api_transport = get_async_transport(config) streaming_transport = get_async_transport(config, for_streaming=True) - assert isinstance(api_transport, AsyncPyqwestTransport) - assert api_transport is get_async_transport(config, for_streaming=False) - assert streaming_transport is not api_transport - assert get_async_transport(config, for_streaming=True) is streaming_transport + assert isinstance(api_transport, AsyncRetryableTransport) + assert isinstance(api_transport.transport, AsyncPyqwestTransport) + assert ( + api_transport.transport + is get_async_transport(config, for_streaming=False).transport + ) + assert streaming_transport.transport is not api_transport.transport + assert ( + get_async_transport(config, for_streaming=True).transport + is streaming_transport.transport + ) finally: reset_transport_caches() @@ -541,7 +596,12 @@ async def test_async_envd_api_client_wiring(test_api_key): try: assert client.base_url == "https://sandbox.e2b.app" - assert client._transport is get_async_transport(config) + assert isinstance(client._transport, AsyncRetryableTransport) + assert ( + client._transport.transport + is get_async_transport(config, retries=0).transport + ) + assert client._transport.retries == 0 for header, value in config.sandbox_headers.items(): assert client.headers[header] == value finally: @@ -688,7 +748,7 @@ def test_sync_api_client_round_trips_through_pyqwest(test_api_key, echo_server): httpx_client = api_client.get_httpx_client() try: - assert isinstance(httpx_client._transport, RateLimitTransport) + assert isinstance(httpx_client._transport, RetryableTransport) assert isinstance(httpx_client._transport.transport, PyqwestTransport) response = httpx_client.request("GET", "/sandboxes") assert response.status_code == 200 @@ -802,7 +862,7 @@ async def test_async_api_client_round_trips_through_pyqwest(test_api_key, echo_s httpx_client = api_client.get_async_httpx_client() try: - assert isinstance(httpx_client._transport, AsyncRateLimitTransport) + assert isinstance(httpx_client._transport, AsyncRetryableTransport) assert isinstance(httpx_client._transport.transport, AsyncPyqwestTransport) response = await httpx_client.request("GET", "/sandboxes") assert response.status_code == 200 @@ -1039,9 +1099,10 @@ def test_sync_closing_one_client_leaves_the_shared_pool_open(test_api_key, echo_ pool = get_sync_pyqwest_transport(proxy_to_config(config.proxy)) try: - assert isinstance(api_httpx._transport, RateLimitTransport) - assert isinstance(envd_api._transport, PyqwestTransport) - assert api_httpx._transport.transport is envd_api._transport + assert isinstance(api_httpx._transport, RetryableTransport) + assert isinstance(envd_api._transport, RetryableTransport) + assert envd_api._transport.retries == 0 + assert api_httpx._transport.transport is envd_api._transport.transport assert api_httpx.request("GET", "/sandboxes").status_code == 200 api_httpx.close() @@ -1068,9 +1129,10 @@ async def test_async_closing_one_client_leaves_the_shared_pool_open( pool = get_async_pyqwest_transport(proxy_to_config(config.proxy)) try: - assert isinstance(api_httpx._transport, AsyncRateLimitTransport) - assert isinstance(envd_api._transport, AsyncPyqwestTransport) - assert api_httpx._transport.transport is envd_api._transport + assert isinstance(api_httpx._transport, AsyncRetryableTransport) + assert isinstance(envd_api._transport, AsyncRetryableTransport) + assert envd_api._transport.retries == 0 + assert api_httpx._transport.transport is envd_api._transport.transport assert (await api_httpx.request("GET", "/sandboxes")).status_code == 200 await api_httpx.aclose() diff --git a/packages/python-sdk/tests/test_rate_limit_retry_transport.py b/packages/python-sdk/tests/test_rate_limit_retry_transport.py index d7dcd5c079..fffdba7c45 100644 --- a/packages/python-sdk/tests/test_rate_limit_retry_transport.py +++ b/packages/python-sdk/tests/test_rate_limit_retry_transport.py @@ -1,7 +1,7 @@ import httpx import pytest -from e2b.retry import AsyncRateLimitTransport, RateLimitTransport, parse_retry_after +from e2b.retry import AsyncRetryableTransport, RetryableTransport, parse_retry_after class FakeTransport(httpx.BaseTransport): @@ -68,7 +68,7 @@ def test_retries_rate_limit_after_server_delay_and_replays_body(): sleeps = [] request = httpx.Request("POST", "https://api.test", content=b"payload") - response = RateLimitTransport(inner, retries=3, sleep=sleeps.append).handle_request( + response = RetryableTransport(inner, retries=3, sleep=sleeps.append).handle_request( request ) @@ -81,7 +81,7 @@ def test_retries_rate_limit_after_server_delay_and_replays_body(): def test_exhaustion_returns_final_rate_limit_response(): inner = FakeTransport([429, 429, 429]) - response = RateLimitTransport( + response = RetryableTransport( inner, retries=2, sleep=lambda _: None ).handle_request(httpx.Request("GET", "https://api.test")) @@ -95,7 +95,7 @@ def test_exhaustion_returns_final_rate_limit_response(): def test_does_not_retry_without_delta_seconds(retry_after): inner = FakeTransport([429], retry_after=retry_after) - response = RateLimitTransport(inner, retries=3).handle_request( + response = RetryableTransport(inner, retries=3).handle_request( httpx.Request("GET", "https://api.test") ) @@ -106,7 +106,7 @@ def test_does_not_retry_without_delta_seconds(retry_after): def test_429_without_retry_after_is_propagated_as_is(): inner = FakeTransport([429], retry_after=None) - response = RateLimitTransport(inner, retries=3).handle_request( + response = RetryableTransport(inner, retries=3).handle_request( httpx.Request("GET", "https://api.test") ) @@ -123,7 +123,7 @@ def test_retry_after_exceeding_timeout_is_propagated_as_is(): extensions={"timeout": {"connect": 3.0, "read": 3.0}}, ) - response = RateLimitTransport( + response = RetryableTransport( inner, retries=3, sleep=sleeps.append, monotonic=lambda: 0.0 ).handle_request(request) @@ -143,7 +143,7 @@ def test_retry_wait_is_bounded_when_request_timeout_is_disabled(): }, ) - response = RateLimitTransport( + response = RetryableTransport( inner, retries=3, sleep=sleeps.append, monotonic=lambda: 0.0 ).handle_request(request) @@ -155,7 +155,7 @@ def test_retry_wait_is_bounded_when_request_timeout_is_disabled(): def test_does_not_retry_other_status_codes(): inner = FakeTransport([503], retry_after="0") - response = RateLimitTransport(inner, retries=3).handle_request( + response = RetryableTransport(inner, retries=3).handle_request( httpx.Request("GET", "https://api.test") ) @@ -174,7 +174,7 @@ def test_does_not_retry_when_cumulative_wait_reaches_request_timeout(): }, ) - response = RateLimitTransport( + response = RetryableTransport( inner, retries=3, sleep=clock.sleep, @@ -201,7 +201,7 @@ def sleep(delay): ) with pytest.raises(httpx.TimeoutException, match=r"request_timeout.*retries"): - RateLimitTransport( + RetryableTransport( inner, retries=1, sleep=sleep, @@ -226,7 +226,7 @@ def handle_request(self, request): extensions={"timeout": {"connect": 3.0, "read": 3.0}}, ) - response = RateLimitTransport( + response = RetryableTransport( inner, retries=1, sleep=clock.sleep, monotonic=clock.monotonic ).handle_request(request) @@ -256,7 +256,7 @@ def handle_request(self, request): }, ) - RateLimitTransport( + RetryableTransport( inner, retries=1, sleep=clock.sleep, monotonic=clock.monotonic ).handle_request(request) @@ -272,7 +272,7 @@ def test_zero_retries_passes_the_original_request_through(): inner = FakeTransport([429], retry_after="0") request = httpx.Request("GET", "https://api.test") - response = RateLimitTransport(inner, retries=0).handle_request(request) + response = RetryableTransport(inner, retries=0).handle_request(request) assert response.status_code == 429 assert inner.requests == [request] @@ -286,7 +286,7 @@ def __iter__(self): inner = FakeTransport([429]) request = httpx.Request("POST", "https://api.test", content=Stream()) - response = RateLimitTransport(inner, retries=3).handle_request(request) + response = RetryableTransport(inner, retries=3).handle_request(request) assert response.status_code == 429 assert inner.requests == [request] @@ -295,7 +295,7 @@ def __iter__(self): def test_close_leaves_cached_transport_open(): inner = FakeTransport([]) - RateLimitTransport(inner, retries=1).close() + RetryableTransport(inner, retries=1).close() assert not inner.closed @@ -308,7 +308,7 @@ async def test_async_retries_rate_limit_and_replays_body(): async def sleep(delay): sleeps.append(delay) - response = await AsyncRateLimitTransport( + response = await AsyncRetryableTransport( inner, retries=3, sleep=sleep ).handle_async_request( httpx.Request("POST", "https://api.test", content=b"payload") @@ -328,7 +328,7 @@ async def test_async_does_not_retry_when_wait_reaches_request_timeout(): async def sleep(delay): sleeps.append(delay) - response = await AsyncRateLimitTransport( + response = await AsyncRetryableTransport( inner, retries=3, sleep=sleep, monotonic=lambda: 0.0 ).handle_async_request( httpx.Request( @@ -359,7 +359,7 @@ async def sleep(delay): ) with pytest.raises(httpx.TimeoutException, match=r"request_timeout.*retries"): - await AsyncRateLimitTransport( + await AsyncRetryableTransport( inner, retries=1, sleep=sleep, @@ -371,7 +371,7 @@ async def sleep(delay): async def test_async_429_without_retry_after_is_propagated_as_is(): inner = FakeAsyncTransport([429], retry_after=None) - response = await AsyncRateLimitTransport(inner, retries=3).handle_async_request( + response = await AsyncRetryableTransport(inner, retries=3).handle_async_request( httpx.Request("GET", "https://api.test") ) @@ -387,7 +387,7 @@ async def test_async_retry_after_exceeding_timeout_is_propagated_as_is(): async def sleep(delay): sleeps.append(delay) - response = await AsyncRateLimitTransport( + response = await AsyncRetryableTransport( inner, retries=3, sleep=sleep, monotonic=lambda: 0.0 ).handle_async_request( httpx.Request( @@ -410,7 +410,7 @@ async def test_async_retry_wait_is_bounded_when_request_timeout_is_disabled(): async def sleep(delay): sleeps.append(delay) - response = await AsyncRateLimitTransport( + response = await AsyncRetryableTransport( inner, retries=3, sleep=sleep, monotonic=lambda: 0.0 ).handle_async_request( httpx.Request( @@ -448,7 +448,7 @@ async def handle_async_request(self, request): async def sleep(delay): clock.sleep(delay) - response = await AsyncRateLimitTransport( + response = await AsyncRetryableTransport( inner, retries=1, sleep=sleep, monotonic=clock.monotonic ).handle_async_request( httpx.Request( @@ -481,7 +481,7 @@ async def handle_async_request(self, request): async def sleep(delay): clock.sleep(delay) - await AsyncRateLimitTransport( + await AsyncRetryableTransport( inner, retries=1, sleep=sleep, monotonic=clock.monotonic ).handle_async_request( httpx.Request( @@ -515,7 +515,7 @@ async def __aiter__(self): inner = FakeAsyncTransport([429]) request = httpx.Request("POST", "https://api.test", content=Stream()) - response = await AsyncRateLimitTransport(inner, retries=3).handle_async_request( + response = await AsyncRetryableTransport(inner, retries=3).handle_async_request( request ) @@ -527,7 +527,7 @@ async def __aiter__(self): async def test_async_close_leaves_cached_transport_open(): inner = FakeAsyncTransport([]) - await AsyncRateLimitTransport(inner, retries=1).aclose() + await AsyncRetryableTransport(inner, retries=1).aclose() assert not inner.closed diff --git a/packages/python-sdk/tests/test_volume_client.py b/packages/python-sdk/tests/test_volume_client.py index 8e7c251ca7..5e6f9e4208 100644 --- a/packages/python-sdk/tests/test_volume_client.py +++ b/packages/python-sdk/tests/test_volume_client.py @@ -148,13 +148,21 @@ def test_volume_transports_are_the_shared_sdk_pools(test_api_key): api_config = ConnectionConfig(api_key=test_api_key) try: - assert get_sync_transport(config) is api_client_sync.get_transport(api_config) - assert get_sync_streaming_transport(config) is api_client_sync.get_transport( - api_config, for_streaming=True + assert ( + get_sync_transport(config) + is api_client_sync.get_transport(api_config).transport ) - assert get_async_transport(config) is api_client_async.get_transport(api_config) - assert get_async_streaming_transport(config) is api_client_async.get_transport( - api_config, for_streaming=True + assert ( + get_sync_streaming_transport(config) + is api_client_sync.get_transport(api_config, for_streaming=True).transport + ) + assert ( + get_async_transport(config) + is api_client_async.get_transport(api_config).transport + ) + assert ( + get_async_streaming_transport(config) + is api_client_async.get_transport(api_config, for_streaming=True).transport ) finally: reset_volume_transports() From 1ea931a7cdf5653a04e6223037bac06f0f9b19c0 Mon Sep 17 00:00:00 2001 From: nalekseev-e2b Date: Wed, 9 Sep 2026 15:49:38 +0200 Subject: [PATCH 09/16] chore(python): refresh code interpreter lockfile --- packages/code-interpreter-python/uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/code-interpreter-python/uv.lock b/packages/code-interpreter-python/uv.lock index 24a72ab2cb..f3d94c3fb3 100644 --- a/packages/code-interpreter-python/uv.lock +++ b/packages/code-interpreter-python/uv.lock @@ -358,7 +358,7 @@ wheels = [ [[package]] name = "e2b" -version = "2.48.0" +version = "2.49.0" source = { editable = "../python-sdk" } dependencies = [ { name = "attrs" }, From b755ea34c60b831a6775273962609e79a33f5ca8 Mon Sep 17 00:00:00 2001 From: nalekseev-e2b Date: Wed, 9 Sep 2026 15:55:15 +0200 Subject: [PATCH 10/16] refactor(js): centralize retry fetch wrapping --- packages/js-sdk/src/api/http2.ts | 11 ++++++++++- packages/js-sdk/src/api/index.ts | 5 ++--- packages/js-sdk/src/retry.ts | 2 +- packages/js-sdk/src/volume/client.ts | 2 +- packages/js-sdk/tests/api/http2.test.ts | 20 ++++++++++++++------ packages/js-sdk/tests/retry.test.ts | 20 ++++++++------------ 6 files changed, 36 insertions(+), 24 deletions(-) diff --git a/packages/js-sdk/src/api/http2.ts b/packages/js-sdk/src/api/http2.ts index 2a46c3fcb4..a5bb79d279 100644 --- a/packages/js-sdk/src/api/http2.ts +++ b/packages/js-sdk/src/api/http2.ts @@ -1,4 +1,5 @@ import { runtime } from '../utils' +import { withRetry } from '../retry' import { parseInflightLimitEnv, parsePositiveIntEnv } from './metadata' import { buildDispatchedFetch, @@ -15,7 +16,15 @@ const DEFAULT_API_INFLIGHT_LIMIT = 1000 // single dispatcher while each distinct proxy URL gets its own. const apiFetchers = new Map() -export function createApiFetch(proxy?: string): typeof fetch { +export function createApiFetch( + proxy: string | undefined, + retries: number, + requestTimeoutMs: number +): typeof fetch { + return withRetry(getApiFetch(proxy), retries, requestTimeoutMs) +} + +export function getApiFetch(proxy?: string): typeof fetch { const key = proxy ?? '' const cached = apiFetchers.get(key) diff --git a/packages/js-sdk/src/api/index.ts b/packages/js-sdk/src/api/index.ts index de6289f587..ff61da7922 100644 --- a/packages/js-sdk/src/api/index.ts +++ b/packages/js-sdk/src/api/index.ts @@ -11,7 +11,6 @@ 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 @@ -106,8 +105,8 @@ class ApiClient { this.api = createClient({ baseUrl: config.apiUrl, - fetch: withRateLimitRetry( - createApiFetch(config.proxy), + fetch: createApiFetch( + config.proxy, config.retries, config.requestTimeoutMs ), diff --git a/packages/js-sdk/src/retry.ts b/packages/js-sdk/src/retry.ts index dcf0f328bf..5e199785cc 100644 --- a/packages/js-sdk/src/retry.ts +++ b/packages/js-sdk/src/retry.ts @@ -48,7 +48,7 @@ function wait(delayMs: number, signal: AbortSignal): Promise { } /** Retry replayable requests after a 429 carrying `Retry-After`. */ -export function withRateLimitRetry( +export function withRetry( fetchImpl: typeof fetch, retries: number, requestTimeoutMs: number, diff --git a/packages/js-sdk/src/volume/client.ts b/packages/js-sdk/src/volume/client.ts index cda7944e52..c9f7293c44 100644 --- a/packages/js-sdk/src/volume/client.ts +++ b/packages/js-sdk/src/volume/client.ts @@ -120,7 +120,7 @@ class VolumeApiClient { constructor(config: VolumeConnectionConfig) { this.api = createClient({ baseUrl: config.apiUrl, - fetch: createApiFetch(config.proxy), + fetch: createApiFetch(config.proxy, 0, config.requestTimeoutMs), headers: { ...defaultHeaders, ...(config.token && { Authorization: `Bearer ${config.token}` }), diff --git a/packages/js-sdk/tests/api/http2.test.ts b/packages/js-sdk/tests/api/http2.test.ts index 262bf96bf3..72334c334c 100644 --- a/packages/js-sdk/tests/api/http2.test.ts +++ b/packages/js-sdk/tests/api/http2.test.ts @@ -147,18 +147,26 @@ test('late-binds the global fetch fallback when undici cannot be loaded', async }) test('caches API fetchers per proxy', async () => { - const { createApiFetch } = await import('../../src/api/http2') + const { getApiFetch } = await import('../../src/api/http2') - const noProxy = createApiFetch() - const proxyA = createApiFetch('http://127.0.0.1:8080') - const proxyB = createApiFetch('http://127.0.0.1:9090') + const noProxy = getApiFetch() + const proxyA = getApiFetch('http://127.0.0.1:8080') + const proxyB = getApiFetch('http://127.0.0.1:9090') - expect(createApiFetch()).toBe(noProxy) - expect(createApiFetch('http://127.0.0.1:8080')).toBe(proxyA) + expect(getApiFetch()).toBe(noProxy) + expect(getApiFetch('http://127.0.0.1:8080')).toBe(proxyA) expect(proxyA).not.toBe(noProxy) expect(proxyA).not.toBe(proxyB) }) +test('creates a new retry wrapper for each API client', async () => { + const { createApiFetch } = await import('../../src/api/http2') + + expect(createApiFetch(undefined, 3, 60_000)).not.toBe( + createApiFetch(undefined, 3, 60_000) + ) +}) + test('getApiConnectionLimit throws on a malformed env value', async () => { process.env.E2B_API_CONNECTIONS = 'not-a-number' diff --git a/packages/js-sdk/tests/retry.test.ts b/packages/js-sdk/tests/retry.test.ts index 45548bb060..3756656c0a 100644 --- a/packages/js-sdk/tests/retry.test.ts +++ b/packages/js-sdk/tests/retry.test.ts @@ -1,10 +1,6 @@ import { describe, expect, test, vi } from 'vitest' -import { - parseRetryAfter, - resolveRetries, - withRateLimitRetry, -} from '../src/retry' +import { parseRetryAfter, resolveRetries, withRetry } from '../src/retry' import { EnvdApiClient } from '../src/envd/api' import { InvalidArgumentError } from '../src/errors' @@ -49,7 +45,7 @@ test('retries a buffered request and cancels the intermediate response', async ( return bodies.length === 1 ? rateLimited : new Response('ok') }) as typeof fetch const sleep = vi.fn(async () => {}) - const fetchWithRetry = withRateLimitRetry(fetchImpl, 1, 10_000, { + const fetchWithRetry = withRetry(fetchImpl, 1, 10_000, { monotonic: () => 0, sleep, }) @@ -72,7 +68,7 @@ test('returns the final 429 after exhausting retries', async () => { headers: { 'Retry-After': '0' }, }) }) as typeof fetch - const fetchWithRetry = withRateLimitRetry(fetchImpl, 2, 10_000, { + const fetchWithRetry = withRetry(fetchImpl, 2, 10_000, { monotonic: () => 0, sleep: async () => {}, }) @@ -91,7 +87,7 @@ test('propagates 429 when Retry-After exceeds the request timeout', async () => }) }) as typeof fetch const sleep = vi.fn(async () => {}) - const fetchWithRetry = withRateLimitRetry(fetchImpl, 1, 1_000, { + const fetchWithRetry = withRetry(fetchImpl, 1, 1_000, { monotonic: () => 0, sleep, }) @@ -112,7 +108,7 @@ test('bounds retry waits when the request timeout is disabled', async () => { }) ) as typeof fetch const sleep = vi.fn(async () => {}) - const fetchWithRetry = withRateLimitRetry(fetchImpl, 3, 0, { + const fetchWithRetry = withRetry(fetchImpl, 3, 0, { monotonic: () => 0, sleep, }) @@ -128,7 +124,7 @@ test('propagates 429 without Retry-After as is', async () => { const rateLimited = new Response('rate limited', { status: 429 }) const fetchImpl = vi.fn(async () => rateLimited) as typeof fetch const sleep = vi.fn(async () => {}) - const fetchWithRetry = withRateLimitRetry(fetchImpl, 2, 10_000, { + const fetchWithRetry = withRetry(fetchImpl, 2, 10_000, { monotonic: () => 0, sleep, }) @@ -147,7 +143,7 @@ test('aborting during Retry-After sleep rejects promptly', async () => { headers: { 'Retry-After': '1' }, }) const fetchImpl = vi.fn(async () => rateLimited) as typeof fetch - const fetchWithRetry = withRateLimitRetry(fetchImpl, 1, 10_000) + const fetchWithRetry = withRetry(fetchImpl, 1, 10_000) const controller = new AbortController() const reason = new Error('cancelled') @@ -164,7 +160,7 @@ test('aborting during Retry-After sleep rejects promptly', async () => { test('disabled retries pass a streaming request through unchanged', async () => { const fetchImpl = vi.fn(async () => new Response('ok')) as typeof fetch - const fetchWithRetry = withRateLimitRetry(fetchImpl, 0, 10_000) + const fetchWithRetry = withRetry(fetchImpl, 0, 10_000) const body = new ReadableStream() const init = { method: 'POST', From a1999e57ca250d0b9529bca2574f2fdddcaade04 Mon Sep 17 00:00:00 2001 From: nalekseev-e2b Date: Wed, 9 Sep 2026 16:05:49 +0200 Subject: [PATCH 11/16] refactor(python): scope retries to API clients --- .../e2b/api/client_async/__init__.py | 29 ++- .../e2b/api/client_sync/__init__.py | 29 ++- .../tests/test_api_client_transport.py | 180 ++++++------------ .../python-sdk/tests/test_volume_client.py | 20 +- 4 files changed, 87 insertions(+), 171 deletions(-) diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index 7e6d20ef53..c549e36d66 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -7,7 +7,7 @@ from pyqwest.httpx import AsyncPyqwestTransport from pyqwest.middleware.retry import RetryMode, RetryTransport -from e2b.retry import AsyncRetryableTransport, resolve_max_retries +from e2b.retry import AsyncRetryableTransport from e2b.api import ( AsyncApiClient, ProxyConfig, @@ -24,7 +24,7 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient: return AsyncApiClient( config, - transport=get_transport(config), + transport=AsyncRetryableTransport(get_transport(config), config.retries), **kwargs, ) @@ -157,8 +157,7 @@ def get_transport( *, for_streaming: bool = False, pool_shard: int = 0, - retries: Optional[int] = None, -) -> AsyncRetryableTransport: +) -> AsyncPyqwestTransport: """The shared httpx transport factory for the control-plane REST API and envd HTTP API (file transfers, health checks). Generic callers use shard zero; :func:`get_envd_transport` supplies a sandbox-specific shard. For TLS @@ -172,11 +171,6 @@ def get_transport( one-connection-per-request closes the connection and the server observes the disconnect. - The shared pool is wrapped with the configured control-plane rate-limit - retry policy. Internal callers that must not retry HTTP responses pass - ``retries=0``; connection-establishment retries remain active in the pool - below this wrapper. - ``for_streaming`` selects the pool carrying ``READ_TIMEOUT``, the idle bound on every read: it resets after each successful read, so it caps how long a streamed download may stall without limiting total transfer time. @@ -185,16 +179,17 @@ def get_transport( downloads take it, and they get their own pool (see :func:`get_pyqwest_transport`). """ - proxy = proxy_to_config(config.proxy) - read_timeout = READ_TIMEOUT if for_streaming else None - max_retries = config.retries if retries is None else resolve_max_retries(retries) - transport = get_httpx_transport(proxy, read_timeout, http2, pool_shard) - return AsyncRetryableTransport(transport, max_retries) + return get_httpx_transport( + proxy_to_config(config.proxy), + READ_TIMEOUT if for_streaming else None, + http2, + pool_shard, + ) def get_envd_transport( config: ConnectionConfig, http2: bool = True, *, for_streaming: bool = False -) -> AsyncRetryableTransport: +) -> AsyncPyqwestTransport: """The envd HTTP API's transport, sharded by sandbox ID. Envd RPC and non-streaming HTTP traffic for one sandbox resolve the same @@ -204,15 +199,13 @@ def get_envd_transport( read-timeout-keyed pool. Kept as a separate factory because generic API transports stay on shard - zero while envd transports use the sandbox's shard. HTTP response retries - are disabled for envd; the underlying pool still retries failed connects. + zero while envd transports use the sandbox's shard. """ return get_transport( config, http2, for_streaming=for_streaming, pool_shard=envd_pool_shard(config), - retries=0, ) diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index 1370227318..289c8bdfcf 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -7,7 +7,7 @@ from pyqwest.httpx import PyqwestTransport from pyqwest.middleware.retry import RetryMode, SyncRetryTransport -from e2b.retry import RetryableTransport, resolve_max_retries +from e2b.retry import RetryableTransport from e2b.api import ( ApiClient, ProxyConfig, @@ -24,7 +24,7 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient: return ApiClient( config, - transport=get_transport(config), + transport=RetryableTransport(get_transport(config), config.retries), **kwargs, ) @@ -156,8 +156,7 @@ def get_transport( *, for_streaming: bool = False, pool_shard: int = 0, - retries: Optional[int] = None, -) -> RetryableTransport: +) -> PyqwestTransport: """The shared httpx transport factory for the control-plane REST API and envd HTTP API (file transfers, health checks). Generic callers use shard zero; :func:`get_envd_transport` supplies a sandbox-specific shard. For TLS @@ -171,11 +170,6 @@ def get_transport( one-connection-per-request closes the connection and the server observes the disconnect. - The shared pool is wrapped with the configured control-plane rate-limit - retry policy. Internal callers that must not retry HTTP responses pass - ``retries=0``; connection-establishment retries remain active in the pool - below this wrapper. - ``for_streaming`` selects the pool carrying ``READ_TIMEOUT``, the idle bound on every read: it resets after each successful read, so it caps how long a streamed download may stall without limiting total transfer time. @@ -184,16 +178,17 @@ def get_transport( downloads take it, and they get their own pool (see :func:`get_pyqwest_transport`). """ - proxy = proxy_to_config(config.proxy) - read_timeout = READ_TIMEOUT if for_streaming else None - max_retries = config.retries if retries is None else resolve_max_retries(retries) - transport = get_httpx_transport(proxy, read_timeout, http2, pool_shard) - return RetryableTransport(transport, max_retries) + return get_httpx_transport( + proxy_to_config(config.proxy), + READ_TIMEOUT if for_streaming else None, + http2, + pool_shard, + ) def get_envd_transport( config: ConnectionConfig, http2: bool = True, *, for_streaming: bool = False -) -> RetryableTransport: +) -> PyqwestTransport: """The envd HTTP API's transport, sharded by sandbox ID. Envd RPC and non-streaming HTTP traffic for one sandbox resolve the same @@ -203,15 +198,13 @@ def get_envd_transport( read-timeout-keyed pool. Kept as a separate factory because generic API transports stay on shard - zero while envd transports use the sandbox's shard. HTTP response retries - are disabled for envd; the underlying pool still retries failed connects. + zero while envd transports use the sandbox's shard. """ return get_transport( config, http2, for_streaming=for_streaming, pool_shard=envd_pool_shard(config), - retries=0, ) diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index 186653f427..f7ac9d5231 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -80,7 +80,7 @@ def test_sync_api_client_proxy_uses_explicit_transport(test_api_key): try: assert "proxy" not in api_client._httpx_args assert isinstance(httpx_client._transport, RetryableTransport) - assert httpx_client._transport.transport is get_sync_transport(config).transport + assert httpx_client._transport.transport is get_sync_transport(config) assert isinstance(httpx_client._transport.transport, PyqwestTransport) assert httpx_client._mounts == {} finally: @@ -96,7 +96,7 @@ def test_sync_retry_policy_wraps_but_does_not_split_cached_transport(test_api_ke ) default_httpx = default_client.get_httpx_client() retrying_httpx = retrying_client.get_httpx_client() - other_retry_policy = get_sync_transport( + raw_transport = get_sync_transport( ConnectionConfig(api_key=test_api_key, retries=1) ) @@ -106,8 +106,8 @@ def test_sync_retry_policy_wraps_but_does_not_split_cached_transport(test_api_ke assert isinstance(retrying_httpx._transport, RetryableTransport) assert retrying_httpx._transport.retries == 2 assert retrying_httpx._transport.transport is default_httpx._transport.transport - assert other_retry_policy.retries == 1 - assert other_retry_policy.transport is default_httpx._transport.transport + assert isinstance(raw_transport, PyqwestTransport) + assert raw_transport is default_httpx._transport.transport finally: retrying_httpx.close() default_httpx.close() @@ -131,14 +131,12 @@ def test_sync_get_transport_keyed_by_proxy(test_api_key): direct_transport = get_sync_transport(direct_config) other_proxy_transport = get_sync_transport(other_proxy_config) - assert proxied_transport.transport is not direct_transport.transport - assert proxied_transport.transport is not other_proxy_transport.transport - assert direct_transport.transport is not other_proxy_transport.transport + assert proxied_transport is not direct_transport + assert proxied_transport is not other_proxy_transport + assert direct_transport is not other_proxy_transport # The same proxy still reuses the cached instance. - assert ( - get_sync_transport(proxied_config).transport is proxied_transport.transport - ) - assert get_sync_transport(direct_config).transport is direct_transport.transport + assert get_sync_transport(proxied_config) is proxied_transport + assert get_sync_transport(direct_config) is direct_transport finally: reset_transport_caches() @@ -159,29 +157,24 @@ def test_sync_transports_keyed_by_http_version(test_api_key): envd_negotiated = get_sync_envd_transport(config) envd_http1 = get_sync_envd_transport(config, http2=False) - assert http1.transport is not negotiated.transport - assert envd_http1.transport is not envd_negotiated.transport + assert http1 is not negotiated + assert envd_http1 is not envd_negotiated # A config without sandbox headers resolves envd to shard zero, so it - # shares the generic transport's pool for each HTTP version while using - # its own wrapper with response retries disabled. - assert envd_negotiated.retries == 0 - assert envd_negotiated.transport is negotiated.transport - assert envd_http1.transport is http1.transport + # shares the generic transport for each HTTP version. + assert envd_negotiated is negotiated + assert envd_http1 is http1 # Each version still has one pool per proxy, and repeat calls with the # same arguments reuse it. - assert get_sync_transport(proxied_config, http2=False).transport not in ( - http1.transport, - negotiated.transport, + assert get_sync_transport(proxied_config, http2=False) not in ( + http1, + negotiated, ) - assert get_sync_transport(config, http2=False).transport is http1.transport - assert get_sync_transport(config).transport is negotiated.transport + assert get_sync_transport(config, http2=False) is http1 + assert get_sync_transport(config) is negotiated + assert get_sync_envd_transport(config, http2=False) is envd_http1 assert ( - get_sync_envd_transport(config, http2=False).transport - is envd_http1.transport - ) - assert ( - get_sync_envd_transport(config, http2=False, for_streaming=True).transport - is not envd_http1.transport + get_sync_envd_transport(config, http2=False, for_streaming=True) + is not envd_http1 ) finally: reset_transport_caches() @@ -199,20 +192,13 @@ def test_sync_envd_transports_are_consistently_sharded_by_sandbox( try: assert envd_pool_shard(first) == envd_pool_shard(same_shard) assert envd_pool_shard(first) != envd_pool_shard(different_shard) - assert ( - get_sync_envd_transport(first).transport - is get_sync_envd_transport(same_shard).transport - ) - assert ( - get_sync_envd_transport(first).transport - is not get_sync_envd_transport(different_shard).transport + assert get_sync_envd_transport(first) is get_sync_envd_transport(same_shard) + assert get_sync_envd_transport(first) is not get_sync_envd_transport( + different_shard ) # Generic API traffic remains on shard zero rather than multiplying # control-plane connections for every envd shard. - assert ( - get_sync_envd_transport(first).transport - is not get_sync_transport(first).transport - ) + assert get_sync_envd_transport(first) is not get_sync_transport(first) finally: reset_transport_caches() @@ -315,17 +301,10 @@ def test_sync_generic_transport_separates_streaming_read_timeout(test_api_key): api_transport = get_sync_transport(config) streaming_transport = get_sync_transport(config, for_streaming=True) - assert isinstance(api_transport, RetryableTransport) - assert isinstance(api_transport.transport, PyqwestTransport) - assert ( - api_transport.transport - is get_sync_transport(config, for_streaming=False).transport - ) - assert streaming_transport.transport is not api_transport.transport - assert ( - get_sync_transport(config, for_streaming=True).transport - is streaming_transport.transport - ) + assert isinstance(api_transport, PyqwestTransport) + assert api_transport is get_sync_transport(config, for_streaming=False) + assert streaming_transport is not api_transport + assert get_sync_transport(config, for_streaming=True) is streaming_transport finally: reset_transport_caches() @@ -339,17 +318,8 @@ def test_sync_envd_api_client_wiring(test_api_key): try: assert client.base_url == "https://sandbox.e2b.app" - assert isinstance(client._transport, RetryableTransport) - assert isinstance(streaming._transport, RetryableTransport) - assert ( - client._transport.transport - is get_sync_transport(config, retries=0).transport - ) - assert client._transport.retries == 0 - assert ( - streaming._transport.transport - is get_sync_transport(config, for_streaming=True, retries=0).transport - ) + assert client._transport is get_sync_transport(config) + assert streaming._transport is get_sync_transport(config, for_streaming=True) for header, value in config.sandbox_headers.items(): assert client.headers[header] == value finally: @@ -391,9 +361,7 @@ async def test_async_api_client_proxy_uses_explicit_transport(test_api_key): try: assert "proxy" not in api_client._httpx_args assert isinstance(httpx_client._transport, AsyncRetryableTransport) - assert ( - httpx_client._transport.transport is get_async_transport(config).transport - ) + assert httpx_client._transport.transport is get_async_transport(config) assert isinstance(httpx_client._transport.transport, AsyncPyqwestTransport) assert httpx_client._mounts == {} finally: @@ -414,14 +382,10 @@ async def test_async_get_transport_keyed_by_proxy(test_api_key): proxied_transport = get_async_transport(proxied_config) direct_transport = get_async_transport(direct_config) - assert proxied_transport.transport is not direct_transport.transport + assert proxied_transport is not direct_transport # The same proxy still reuses the cached instance. - assert ( - get_async_transport(proxied_config).transport is proxied_transport.transport - ) - assert ( - get_async_transport(direct_config).transport is direct_transport.transport - ) + assert get_async_transport(proxied_config) is proxied_transport + assert get_async_transport(direct_config) is direct_transport finally: reset_transport_caches() @@ -437,23 +401,18 @@ async def test_async_transports_keyed_by_http_version(test_api_key): envd_negotiated = get_async_envd_transport(config) envd_http1 = get_async_envd_transport(config, http2=False) - assert http1.transport is not negotiated.transport - assert envd_http1.transport is not envd_negotiated.transport + assert http1 is not negotiated + assert envd_http1 is not envd_negotiated # A config without sandbox headers resolves envd to shard zero, so it - # shares the generic transport's pool for each HTTP version while using - # its own wrapper with response retries disabled. - assert envd_negotiated.retries == 0 - assert envd_negotiated.transport is negotiated.transport - assert envd_http1.transport is http1.transport - assert get_async_transport(config, http2=False).transport is http1.transport - assert get_async_transport(config).transport is negotiated.transport + # shares the generic transport for each HTTP version. + assert envd_negotiated is negotiated + assert envd_http1 is http1 + assert get_async_transport(config, http2=False) is http1 + assert get_async_transport(config) is negotiated + assert get_async_envd_transport(config, http2=False) is envd_http1 assert ( - get_async_envd_transport(config, http2=False).transport - is envd_http1.transport - ) - assert ( - get_async_envd_transport(config, http2=False, for_streaming=True).transport - is not envd_http1.transport + get_async_envd_transport(config, http2=False, for_streaming=True) + is not envd_http1 ) finally: reset_transport_caches() @@ -470,18 +429,11 @@ async def test_async_envd_transports_are_consistently_sharded_by_sandbox( different_shard = sandbox_config(test_api_key, "sbx-1") try: - assert ( - get_async_envd_transport(first).transport - is get_async_envd_transport(same_shard).transport - ) - assert ( - get_async_envd_transport(first).transport - is not get_async_envd_transport(different_shard).transport - ) - assert ( - get_async_envd_transport(first).transport - is not get_async_transport(first).transport + assert get_async_envd_transport(first) is get_async_envd_transport(same_shard) + assert get_async_envd_transport(first) is not get_async_envd_transport( + different_shard ) + assert get_async_envd_transport(first) is not get_async_transport(first) finally: reset_transport_caches() @@ -572,17 +524,10 @@ async def test_async_generic_transport_separates_streaming_read_timeout(test_api api_transport = get_async_transport(config) streaming_transport = get_async_transport(config, for_streaming=True) - assert isinstance(api_transport, AsyncRetryableTransport) - assert isinstance(api_transport.transport, AsyncPyqwestTransport) - assert ( - api_transport.transport - is get_async_transport(config, for_streaming=False).transport - ) - assert streaming_transport.transport is not api_transport.transport - assert ( - get_async_transport(config, for_streaming=True).transport - is streaming_transport.transport - ) + assert isinstance(api_transport, AsyncPyqwestTransport) + assert api_transport is get_async_transport(config, for_streaming=False) + assert streaming_transport is not api_transport + assert get_async_transport(config, for_streaming=True) is streaming_transport finally: reset_transport_caches() @@ -596,12 +541,7 @@ async def test_async_envd_api_client_wiring(test_api_key): try: assert client.base_url == "https://sandbox.e2b.app" - assert isinstance(client._transport, AsyncRetryableTransport) - assert ( - client._transport.transport - is get_async_transport(config, retries=0).transport - ) - assert client._transport.retries == 0 + assert client._transport is get_async_transport(config) for header, value in config.sandbox_headers.items(): assert client.headers[header] == value finally: @@ -1100,9 +1040,8 @@ def test_sync_closing_one_client_leaves_the_shared_pool_open(test_api_key, echo_ try: assert isinstance(api_httpx._transport, RetryableTransport) - assert isinstance(envd_api._transport, RetryableTransport) - assert envd_api._transport.retries == 0 - assert api_httpx._transport.transport is envd_api._transport.transport + assert isinstance(envd_api._transport, PyqwestTransport) + assert api_httpx._transport.transport is envd_api._transport assert api_httpx.request("GET", "/sandboxes").status_code == 200 api_httpx.close() @@ -1130,9 +1069,8 @@ async def test_async_closing_one_client_leaves_the_shared_pool_open( try: assert isinstance(api_httpx._transport, AsyncRetryableTransport) - assert isinstance(envd_api._transport, AsyncRetryableTransport) - assert envd_api._transport.retries == 0 - assert api_httpx._transport.transport is envd_api._transport.transport + assert isinstance(envd_api._transport, AsyncPyqwestTransport) + assert api_httpx._transport.transport is envd_api._transport assert (await api_httpx.request("GET", "/sandboxes")).status_code == 200 await api_httpx.aclose() diff --git a/packages/python-sdk/tests/test_volume_client.py b/packages/python-sdk/tests/test_volume_client.py index 5e6f9e4208..8e7c251ca7 100644 --- a/packages/python-sdk/tests/test_volume_client.py +++ b/packages/python-sdk/tests/test_volume_client.py @@ -148,21 +148,13 @@ def test_volume_transports_are_the_shared_sdk_pools(test_api_key): api_config = ConnectionConfig(api_key=test_api_key) try: - assert ( - get_sync_transport(config) - is api_client_sync.get_transport(api_config).transport + assert get_sync_transport(config) is api_client_sync.get_transport(api_config) + assert get_sync_streaming_transport(config) is api_client_sync.get_transport( + api_config, for_streaming=True ) - assert ( - get_sync_streaming_transport(config) - is api_client_sync.get_transport(api_config, for_streaming=True).transport - ) - assert ( - get_async_transport(config) - is api_client_async.get_transport(api_config).transport - ) - assert ( - get_async_streaming_transport(config) - is api_client_async.get_transport(api_config, for_streaming=True).transport + assert get_async_transport(config) is api_client_async.get_transport(api_config) + assert get_async_streaming_transport(config) is api_client_async.get_transport( + api_config, for_streaming=True ) finally: reset_volume_transports() From 7bce729b094a45c274712ced40406370e49d39ff Mon Sep 17 00:00:00 2001 From: nalekseev-e2b Date: Thu, 10 Sep 2026 10:51:34 +0200 Subject: [PATCH 12/16] fix(js): replay rate-limited requests without cloning bodies --- packages/js-sdk/src/api/http2.ts | 11 +- packages/js-sdk/src/api/index.ts | 6 +- packages/js-sdk/src/retry.ts | 37 ++++++- packages/js-sdk/src/volume/client.ts | 2 +- packages/js-sdk/tests/api/http2.test.ts | 20 ++-- packages/js-sdk/tests/client.test.ts | 23 +++++ packages/js-sdk/tests/retry.test.ts | 129 ++++++++++++++++++++---- 7 files changed, 178 insertions(+), 50 deletions(-) diff --git a/packages/js-sdk/src/api/http2.ts b/packages/js-sdk/src/api/http2.ts index a5bb79d279..2a46c3fcb4 100644 --- a/packages/js-sdk/src/api/http2.ts +++ b/packages/js-sdk/src/api/http2.ts @@ -1,5 +1,4 @@ import { runtime } from '../utils' -import { withRetry } from '../retry' import { parseInflightLimitEnv, parsePositiveIntEnv } from './metadata' import { buildDispatchedFetch, @@ -16,15 +15,7 @@ const DEFAULT_API_INFLIGHT_LIMIT = 1000 // single dispatcher while each distinct proxy URL gets its own. const apiFetchers = new Map() -export function createApiFetch( - proxy: string | undefined, - retries: number, - requestTimeoutMs: number -): typeof fetch { - return withRetry(getApiFetch(proxy), retries, requestTimeoutMs) -} - -export function getApiFetch(proxy?: string): typeof fetch { +export function createApiFetch(proxy?: string): typeof fetch { const key = proxy ?? '' const cached = apiFetchers.get(key) diff --git a/packages/js-sdk/src/api/index.ts b/packages/js-sdk/src/api/index.ts index ff61da7922..9bdcd0a709 100644 --- a/packages/js-sdk/src/api/index.ts +++ b/packages/js-sdk/src/api/index.ts @@ -11,6 +11,7 @@ import { SandboxError, } from '../errors' import { createApiLogger } from '../logs' +import { RetryableRequest, withRateLimitRetry } from '../retry' /** * Map an API error code and message to the matching error class — the same @@ -105,8 +106,9 @@ class ApiClient { this.api = createClient({ baseUrl: config.apiUrl, - fetch: createApiFetch( - config.proxy, + Request: RetryableRequest, + fetch: withRateLimitRetry( + createApiFetch(config.proxy), config.retries, config.requestTimeoutMs ), diff --git a/packages/js-sdk/src/retry.ts b/packages/js-sdk/src/retry.ts index 5e199785cc..5aa62c34b8 100644 --- a/packages/js-sdk/src/retry.ts +++ b/packages/js-sdk/src/retry.ts @@ -31,6 +31,20 @@ type RetryDependencies = { sleep?: (delayMs: number, signal: AbortSignal) => Promise } +// openapi-fetch converts serialized JSON into a Request body stream before +// calling fetch. Keep the immutable string so retries need neither a stream +// tee (which buffers an unread branch) nor an eager read of the whole body. +const requestBodies = new WeakMap() + +export class RetryableRequest extends Request { + constructor(input: RequestInfo | URL, init?: RequestInit) { + super(input, init) + if (typeof init?.body === 'string') { + requestBodies.set(this, init.body) + } + } +} + function wait(delayMs: number, signal: AbortSignal): Promise { if (signal.aborted) return Promise.reject(signal.reason) @@ -48,7 +62,7 @@ function wait(delayMs: number, signal: AbortSignal): Promise { } /** Retry replayable requests after a 429 carrying `Retry-After`. */ -export function withRetry( +export function withRateLimitRetry( fetchImpl: typeof fetch, retries: number, requestTimeoutMs: number, @@ -60,12 +74,29 @@ export function withRetry( return (async (input, init) => { if (retries === 0) return fetchImpl(input, init) - const request = new Request(input as RequestInfo, init) + if (init?.body != null && typeof init.body !== 'string') { + return fetchImpl(input, init) + } + + const request = + input instanceof Request && init === undefined + ? input + : new RetryableRequest(input as RequestInfo, init) + const body = requestBodies.get(request) + // A Request hides its original body type. Unknown bodies (including + // streams) are sent once + if (request.body !== null && body === undefined) { + return fetchImpl(request) + } const deadline = monotonic() + (requestTimeoutMs || MAX_RETRY_WAIT_WITHOUT_TIMEOUT_MS) for (let attempt = 0; ; attempt++) { - const response = await fetchImpl(request.clone()) + const response = await fetchImpl( + attempt === 0 + ? request + : new Request(request, { method: request.method, body }) + ) const retryAfter = parseRetryAfter(response.headers.get('Retry-After')) const delayMs = retryAfter === undefined ? undefined : retryAfter * 1000 diff --git a/packages/js-sdk/src/volume/client.ts b/packages/js-sdk/src/volume/client.ts index c9f7293c44..cda7944e52 100644 --- a/packages/js-sdk/src/volume/client.ts +++ b/packages/js-sdk/src/volume/client.ts @@ -120,7 +120,7 @@ class VolumeApiClient { constructor(config: VolumeConnectionConfig) { this.api = createClient({ baseUrl: config.apiUrl, - fetch: createApiFetch(config.proxy, 0, config.requestTimeoutMs), + fetch: createApiFetch(config.proxy), headers: { ...defaultHeaders, ...(config.token && { Authorization: `Bearer ${config.token}` }), diff --git a/packages/js-sdk/tests/api/http2.test.ts b/packages/js-sdk/tests/api/http2.test.ts index 72334c334c..262bf96bf3 100644 --- a/packages/js-sdk/tests/api/http2.test.ts +++ b/packages/js-sdk/tests/api/http2.test.ts @@ -147,26 +147,18 @@ test('late-binds the global fetch fallback when undici cannot be loaded', async }) test('caches API fetchers per proxy', async () => { - const { getApiFetch } = await import('../../src/api/http2') + const { createApiFetch } = await import('../../src/api/http2') - const noProxy = getApiFetch() - const proxyA = getApiFetch('http://127.0.0.1:8080') - const proxyB = getApiFetch('http://127.0.0.1:9090') + const noProxy = createApiFetch() + const proxyA = createApiFetch('http://127.0.0.1:8080') + const proxyB = createApiFetch('http://127.0.0.1:9090') - expect(getApiFetch()).toBe(noProxy) - expect(getApiFetch('http://127.0.0.1:8080')).toBe(proxyA) + expect(createApiFetch()).toBe(noProxy) + expect(createApiFetch('http://127.0.0.1:8080')).toBe(proxyA) expect(proxyA).not.toBe(noProxy) expect(proxyA).not.toBe(proxyB) }) -test('creates a new retry wrapper for each API client', async () => { - const { createApiFetch } = await import('../../src/api/http2') - - expect(createApiFetch(undefined, 3, 60_000)).not.toBe( - createApiFetch(undefined, 3, 60_000) - ) -}) - test('getApiConnectionLimit throws on a malformed env value', async () => { process.env.E2B_API_CONNECTIONS = 'not-a-number' diff --git a/packages/js-sdk/tests/client.test.ts b/packages/js-sdk/tests/client.test.ts index 44b64e3932..b277fb8969 100644 --- a/packages/js-sdk/tests/client.test.ts +++ b/packages/js-sdk/tests/client.test.ts @@ -191,6 +191,29 @@ test('client retries rate-limited control-plane requests', async () => { 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 diff --git a/packages/js-sdk/tests/retry.test.ts b/packages/js-sdk/tests/retry.test.ts index 3756656c0a..378d175d40 100644 --- a/packages/js-sdk/tests/retry.test.ts +++ b/packages/js-sdk/tests/retry.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test, vi } from 'vitest' -import { parseRetryAfter, resolveRetries, withRetry } from '../src/retry' +import { + parseRetryAfter, + resolveRetries, + RetryableRequest, + withRateLimitRetry, +} from '../src/retry' import { EnvdApiClient } from '../src/envd/api' import { InvalidArgumentError } from '../src/errors' @@ -45,7 +50,7 @@ test('retries a buffered request and cancels the intermediate response', async ( return bodies.length === 1 ? rateLimited : new Response('ok') }) as typeof fetch const sleep = vi.fn(async () => {}) - const fetchWithRetry = withRetry(fetchImpl, 1, 10_000, { + const fetchWithRetry = withRateLimitRetry(fetchImpl, 1, 10_000, { monotonic: () => 0, sleep, }) @@ -61,6 +66,84 @@ test('retries a buffered request and cancels the intermediate response', async ( expect(rateLimited.bodyUsed).toBe(true) }) +test.each(['', '{"templateID":"base"}'])( + 'replays a serialized API body %j without cloning the stream', + async (body) => { + const controller = new AbortController() + const request = new RetryableRequest('https://api.e2b.test/resource', { + method: 'POST', + body, + headers: { 'Content-Type': 'application/json', 'X-API-KEY': 'test-key' }, + signal: controller.signal, + redirect: 'manual', + credentials: 'include', + }) + const tee = vi.spyOn(ReadableStream.prototype, 'tee') + const attempts: Request[] = [] + const bodies: string[] = [] + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const attempt = input as Request + attempts.push(attempt) + bodies.push(await attempt.text()) + return new Response(null, { + status: attempts.length < 3 ? 429 : 200, + headers: { 'Retry-After': '0' }, + }) + }) as typeof fetch + try { + const response = await withRateLimitRetry(fetchImpl, 2, 10_000)(request) + expect(response.status).toBe(200) + expect(bodies).toEqual([body, body, body]) + expect(new Set(attempts).size).toBe(3) + expect(tee).not.toHaveBeenCalled() + controller.abort() + for (const attempt of attempts) { + expect(attempt.url).toBe(request.url) + expect(attempt.method).toBe('POST') + expect([...attempt.headers]).toEqual([...request.headers]) + expect(attempt.redirect).toBe('manual') + expect(attempt.credentials).toBe('include') + expect(attempt.signal.aborted).toBe(true) + } + } finally { + tee.mockRestore() + } + } +) + +test.each([false, true])( + 'sends an opaque body once without reading or teeing it (custom Request: %s)', + async (customRequest) => { + const pull = vi.fn() + const body = new ReadableStream({ pull }, { highWaterMark: 0 }) + const RequestClass = customRequest ? RetryableRequest : Request + const request = new RequestClass('https://api.e2b.test/resource', { + method: 'POST', + body, + duplex: 'half', + } as RequestInit) + const tee = vi.spyOn(body, 'tee') + const rateLimited = new Response('rate limited', { + status: 429, + headers: { 'Retry-After': '0' }, + }) + const fetchImpl = vi.fn(async () => rateLimited) as typeof fetch + const sleep = vi.fn(async () => {}) + + const response = await withRateLimitRetry(fetchImpl, 3, 10_000, { + sleep, + })(request) + + expect(response).toBe(rateLimited) + expect(response.bodyUsed).toBe(false) + expect(fetchImpl).toHaveBeenCalledExactlyOnceWith(request) + expect(sleep).not.toHaveBeenCalled() + expect(pull).not.toHaveBeenCalled() + expect(tee).not.toHaveBeenCalled() + expect(body.locked).toBe(false) + } +) + test('returns the final 429 after exhausting retries', async () => { const fetchImpl = vi.fn(async () => { return new Response(null, { @@ -68,7 +151,7 @@ test('returns the final 429 after exhausting retries', async () => { headers: { 'Retry-After': '0' }, }) }) as typeof fetch - const fetchWithRetry = withRetry(fetchImpl, 2, 10_000, { + const fetchWithRetry = withRateLimitRetry(fetchImpl, 2, 10_000, { monotonic: () => 0, sleep: async () => {}, }) @@ -87,7 +170,7 @@ test('propagates 429 when Retry-After exceeds the request timeout', async () => }) }) as typeof fetch const sleep = vi.fn(async () => {}) - const fetchWithRetry = withRetry(fetchImpl, 1, 1_000, { + const fetchWithRetry = withRateLimitRetry(fetchImpl, 1, 1_000, { monotonic: () => 0, sleep, }) @@ -108,7 +191,7 @@ test('bounds retry waits when the request timeout is disabled', async () => { }) ) as typeof fetch const sleep = vi.fn(async () => {}) - const fetchWithRetry = withRetry(fetchImpl, 3, 0, { + const fetchWithRetry = withRateLimitRetry(fetchImpl, 3, 0, { monotonic: () => 0, sleep, }) @@ -124,7 +207,7 @@ test('propagates 429 without Retry-After as is', async () => { const rateLimited = new Response('rate limited', { status: 429 }) const fetchImpl = vi.fn(async () => rateLimited) as typeof fetch const sleep = vi.fn(async () => {}) - const fetchWithRetry = withRetry(fetchImpl, 2, 10_000, { + const fetchWithRetry = withRateLimitRetry(fetchImpl, 2, 10_000, { monotonic: () => 0, sleep, }) @@ -143,7 +226,7 @@ test('aborting during Retry-After sleep rejects promptly', async () => { headers: { 'Retry-After': '1' }, }) const fetchImpl = vi.fn(async () => rateLimited) as typeof fetch - const fetchWithRetry = withRetry(fetchImpl, 1, 10_000) + const fetchWithRetry = withRateLimitRetry(fetchImpl, 1, 10_000) const controller = new AbortController() const reason = new Error('cancelled') @@ -158,21 +241,27 @@ test('aborting during Retry-After sleep rejects promptly', async () => { expect(fetchImpl).toHaveBeenCalledOnce() }) -test('disabled retries pass a streaming request through unchanged', async () => { - const fetchImpl = vi.fn(async () => new Response('ok')) as typeof fetch - const fetchWithRetry = withRetry(fetchImpl, 0, 10_000) - const body = new ReadableStream() - const init = { - method: 'POST', - body, - duplex: 'half' as const, - } +test.each([0, 3])( + 'retries=%s passes a streaming request through unchanged', + async (retries) => { + const fetchImpl = vi.fn(async () => new Response('ok')) as typeof fetch + const fetchWithRetry = withRateLimitRetry(fetchImpl, retries, 10_000) + const body = new ReadableStream() + const init = { + method: 'POST', + body, + duplex: 'half' as const, + } - await fetchWithRetry('https://api.e2b.test/resource', init) + await fetchWithRetry('https://api.e2b.test/resource', init) - expect(fetchImpl).toHaveBeenCalledWith('https://api.e2b.test/resource', init) - expect(body.locked).toBe(false) -}) + expect(fetchImpl).toHaveBeenCalledWith( + 'https://api.e2b.test/resource', + init + ) + expect(body.locked).toBe(false) + } +) test('envd clients do not retry rate-limited requests', async () => { const fetchImpl = vi.fn( From 378aadcd971fdb1b02d251e7db973271910de081 Mon Sep 17 00:00:00 2001 From: nalekseev-e2b Date: Thu, 10 Sep 2026 11:07:50 +0200 Subject: [PATCH 13/16] test(js): preserve runtime-specific request credentials --- packages/js-sdk/tests/retry.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/js-sdk/tests/retry.test.ts b/packages/js-sdk/tests/retry.test.ts index 378d175d40..ce89b60ba4 100644 --- a/packages/js-sdk/tests/retry.test.ts +++ b/packages/js-sdk/tests/retry.test.ts @@ -102,7 +102,8 @@ test.each(['', '{"templateID":"base"}'])( expect(attempt.method).toBe('POST') expect([...attempt.headers]).toEqual([...request.headers]) expect(attempt.redirect).toBe('manual') - expect(attempt.credentials).toBe('include') + // Deno and Cloudflare do not expose Request.credentials. + expect(attempt.credentials).toBe(request.credentials) expect(attempt.signal.aborted).toBe(true) } } finally { From c64e32d4905a6056591f222fc694c8fb1fe5d52e Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:50:35 +0200 Subject: [PATCH 14/16] refactor(js): gate retries on body streamability and replay via clone Mirror the Python transport's ByteStream check: mark whether the body is a stream at RetryableRequest construction using isReadableStreamLike and send streaming bodies once, replaying everything else with clone() instead of capturing serialized bodies in a WeakMap. Co-Authored-By: Claude Fable 5 --- packages/js-sdk/src/retry.ts | 38 +++++++++++++++-------------- packages/js-sdk/tests/retry.test.ts | 36 ++++++++++++--------------- 2 files changed, 36 insertions(+), 38 deletions(-) diff --git a/packages/js-sdk/src/retry.ts b/packages/js-sdk/src/retry.ts index 5aa62c34b8..4d960d9210 100644 --- a/packages/js-sdk/src/retry.ts +++ b/packages/js-sdk/src/retry.ts @@ -1,4 +1,5 @@ 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 @@ -31,17 +32,20 @@ type RetryDependencies = { sleep?: (delayMs: number, signal: AbortSignal) => Promise } -// openapi-fetch converts serialized JSON into a Request body stream before -// calling fetch. Keep the immutable string so retries need neither a stream -// tee (which buffers an unread branch) nor an eager read of the whole body. -const requestBodies = new WeakMap() - export class RetryableRequest extends Request { + /** + * Whether the request can be replayed with `clone()`. A constructed Request + * hides its body's origin, so remember it here: streaming bodies may be + * consumed by the first attempt and cannot be replayed without buffering + * them, while buffered bodies clone for free. + */ + readonly replayable: boolean + constructor(input: RequestInfo | URL, init?: RequestInit) { super(input, init) - if (typeof init?.body === 'string') { - requestBodies.set(this, init.body) - } + this.replayable = + this.body === null || + (init?.body != null && !isReadableStreamLike(init.body)) } } @@ -72,9 +76,7 @@ export function withRateLimitRetry( const sleep = dependencies.sleep ?? wait return (async (input, init) => { - if (retries === 0) return fetchImpl(input, init) - - if (init?.body != null && typeof init.body !== 'string') { + if (retries === 0 || isReadableStreamLike(init?.body)) { return fetchImpl(input, init) } @@ -82,20 +84,20 @@ export function withRateLimitRetry( input instanceof Request && init === undefined ? input : new RetryableRequest(input as RequestInfo, init) - const body = requestBodies.get(request) - // A Request hides its original body type. Unknown bodies (including - // streams) are sent once - if (request.body !== null && body === undefined) { + const replayable = + request instanceof RetryableRequest + ? request.replayable + : request.body === null + if (!replayable) { return fetchImpl(request) } + const deadline = monotonic() + (requestTimeoutMs || MAX_RETRY_WAIT_WITHOUT_TIMEOUT_MS) for (let attempt = 0; ; attempt++) { const response = await fetchImpl( - attempt === 0 - ? request - : new Request(request, { method: request.method, body }) + attempt === retries ? request : request.clone() ) const retryAfter = parseRetryAfter(response.headers.get('Retry-After')) const delayMs = retryAfter === undefined ? undefined : retryAfter * 1000 diff --git a/packages/js-sdk/tests/retry.test.ts b/packages/js-sdk/tests/retry.test.ts index ce89b60ba4..5ed696c06b 100644 --- a/packages/js-sdk/tests/retry.test.ts +++ b/packages/js-sdk/tests/retry.test.ts @@ -67,7 +67,7 @@ test('retries a buffered request and cancels the intermediate response', async ( }) test.each(['', '{"templateID":"base"}'])( - 'replays a serialized API body %j without cloning the stream', + 'replays a serialized API body %j across attempts', async (body) => { const controller = new AbortController() const request = new RetryableRequest('https://api.e2b.test/resource', { @@ -78,7 +78,6 @@ test.each(['', '{"templateID":"base"}'])( redirect: 'manual', credentials: 'include', }) - const tee = vi.spyOn(ReadableStream.prototype, 'tee') const attempts: Request[] = [] const bodies: string[] = [] const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { @@ -90,24 +89,21 @@ test.each(['', '{"templateID":"base"}'])( headers: { 'Retry-After': '0' }, }) }) as typeof fetch - try { - const response = await withRateLimitRetry(fetchImpl, 2, 10_000)(request) - expect(response.status).toBe(200) - expect(bodies).toEqual([body, body, body]) - expect(new Set(attempts).size).toBe(3) - expect(tee).not.toHaveBeenCalled() - controller.abort() - for (const attempt of attempts) { - expect(attempt.url).toBe(request.url) - expect(attempt.method).toBe('POST') - expect([...attempt.headers]).toEqual([...request.headers]) - expect(attempt.redirect).toBe('manual') - // Deno and Cloudflare do not expose Request.credentials. - expect(attempt.credentials).toBe(request.credentials) - expect(attempt.signal.aborted).toBe(true) - } - } finally { - tee.mockRestore() + + const response = await withRateLimitRetry(fetchImpl, 2, 10_000)(request) + + expect(response.status).toBe(200) + expect(bodies).toEqual([body, body, body]) + expect(new Set(attempts).size).toBe(3) + controller.abort() + for (const attempt of attempts) { + expect(attempt.url).toBe(request.url) + expect(attempt.method).toBe('POST') + expect([...attempt.headers]).toEqual([...request.headers]) + expect(attempt.redirect).toBe('manual') + // Deno and Cloudflare do not expose Request.credentials. + expect(attempt.credentials).toBe(request.credentials) + expect(attempt.signal.aborted).toBe(true) } } ) From acb9c72d6925f158a328b59c8749eb487d544bec Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:45 +0200 Subject: [PATCH 15/16] refactor(js): drop RetryableRequest, clone requests for replay The retry wrapper is only installed on the control-plane client, where openapi-fetch serializes every body to a string before constructing the Request, so Request-form inputs are always safe to clone. The streamability gate on init bodies stays for direct callers. Co-Authored-By: Claude Fable 5 --- packages/js-sdk/src/api/index.ts | 3 +-- packages/js-sdk/src/retry.ts | 33 ++++++-------------------- packages/js-sdk/tests/retry.test.ts | 36 +---------------------------- 3 files changed, 9 insertions(+), 63 deletions(-) diff --git a/packages/js-sdk/src/api/index.ts b/packages/js-sdk/src/api/index.ts index 9bdcd0a709..de6289f587 100644 --- a/packages/js-sdk/src/api/index.ts +++ b/packages/js-sdk/src/api/index.ts @@ -11,7 +11,7 @@ import { SandboxError, } from '../errors' import { createApiLogger } from '../logs' -import { RetryableRequest, withRateLimitRetry } from '../retry' +import { withRateLimitRetry } from '../retry' /** * Map an API error code and message to the matching error class — the same @@ -106,7 +106,6 @@ class ApiClient { this.api = createClient({ baseUrl: config.apiUrl, - Request: RetryableRequest, fetch: withRateLimitRetry( createApiFetch(config.proxy), config.retries, diff --git a/packages/js-sdk/src/retry.ts b/packages/js-sdk/src/retry.ts index 4d960d9210..17f8f3b904 100644 --- a/packages/js-sdk/src/retry.ts +++ b/packages/js-sdk/src/retry.ts @@ -32,23 +32,6 @@ type RetryDependencies = { sleep?: (delayMs: number, signal: AbortSignal) => Promise } -export class RetryableRequest extends Request { - /** - * Whether the request can be replayed with `clone()`. A constructed Request - * hides its body's origin, so remember it here: streaming bodies may be - * consumed by the first attempt and cannot be replayed without buffering - * them, while buffered bodies clone for free. - */ - readonly replayable: boolean - - constructor(input: RequestInfo | URL, init?: RequestInit) { - super(input, init) - this.replayable = - this.body === null || - (init?.body != null && !isReadableStreamLike(init.body)) - } -} - function wait(delayMs: number, signal: AbortSignal): Promise { if (signal.aborted) return Promise.reject(signal.reason) @@ -76,22 +59,20 @@ export function withRateLimitRetry( 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 RetryableRequest(input as RequestInfo, init) - const replayable = - request instanceof RetryableRequest - ? request.replayable - : request.body === null - if (!replayable) { - return fetchImpl(request) - } - + : new Request(input as RequestInfo, init) const deadline = monotonic() + (requestTimeoutMs || MAX_RETRY_WAIT_WITHOUT_TIMEOUT_MS) diff --git a/packages/js-sdk/tests/retry.test.ts b/packages/js-sdk/tests/retry.test.ts index 5ed696c06b..dfb9347821 100644 --- a/packages/js-sdk/tests/retry.test.ts +++ b/packages/js-sdk/tests/retry.test.ts @@ -3,7 +3,6 @@ import { describe, expect, test, vi } from 'vitest' import { parseRetryAfter, resolveRetries, - RetryableRequest, withRateLimitRetry, } from '../src/retry' import { EnvdApiClient } from '../src/envd/api' @@ -70,7 +69,7 @@ test.each(['', '{"templateID":"base"}'])( 'replays a serialized API body %j across attempts', async (body) => { const controller = new AbortController() - const request = new RetryableRequest('https://api.e2b.test/resource', { + const request = new Request('https://api.e2b.test/resource', { method: 'POST', body, headers: { 'Content-Type': 'application/json', 'X-API-KEY': 'test-key' }, @@ -108,39 +107,6 @@ test.each(['', '{"templateID":"base"}'])( } ) -test.each([false, true])( - 'sends an opaque body once without reading or teeing it (custom Request: %s)', - async (customRequest) => { - const pull = vi.fn() - const body = new ReadableStream({ pull }, { highWaterMark: 0 }) - const RequestClass = customRequest ? RetryableRequest : Request - const request = new RequestClass('https://api.e2b.test/resource', { - method: 'POST', - body, - duplex: 'half', - } as RequestInit) - const tee = vi.spyOn(body, 'tee') - const rateLimited = new Response('rate limited', { - status: 429, - headers: { 'Retry-After': '0' }, - }) - const fetchImpl = vi.fn(async () => rateLimited) as typeof fetch - const sleep = vi.fn(async () => {}) - - const response = await withRateLimitRetry(fetchImpl, 3, 10_000, { - sleep, - })(request) - - expect(response).toBe(rateLimited) - expect(response.bodyUsed).toBe(false) - expect(fetchImpl).toHaveBeenCalledExactlyOnceWith(request) - expect(sleep).not.toHaveBeenCalled() - expect(pull).not.toHaveBeenCalled() - expect(tee).not.toHaveBeenCalled() - expect(body.locked).toBe(false) - } -) - test('returns the final 429 after exhausting retries', async () => { const fetchImpl = vi.fn(async () => { return new Response(null, { From ad59f739217e55636a186df533b518e99705a425 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:58:30 +0200 Subject: [PATCH 16/16] chore(js): drop redundant RequestInfo cast Co-Authored-By: Claude Fable 5 --- packages/js-sdk/src/retry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/js-sdk/src/retry.ts b/packages/js-sdk/src/retry.ts index 17f8f3b904..7ad186ba9c 100644 --- a/packages/js-sdk/src/retry.ts +++ b/packages/js-sdk/src/retry.ts @@ -72,7 +72,7 @@ export function withRateLimitRetry( const request = input instanceof Request && init === undefined ? input - : new Request(input as RequestInfo, init) + : new Request(input, init) const deadline = monotonic() + (requestTimeoutMs || MAX_RETRY_WAIT_WITHOUT_TIMEOUT_MS)