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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 61 additions & 10 deletions packages/js-sdk/src/api/http2.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { runtime } from '../utils'
import { parseInflightLimitEnv, parsePositiveIntEnv } from './metadata'
import { parseInflightLimitEnv, parseIntEnv, parsePositiveIntEnv } from './metadata'
import { limitConcurrency } from './inflight'
import {
loadUndici,
Expand All @@ -9,6 +9,8 @@ import {
} from '../undici'

const DEFAULT_API_CONNECTION_LIMIT = 100
const DEFAULT_REQUEST_RETRIES = 3
const RETRYABLE_STATUS_CODES = new Set([502, 503, 504])
// 1000 = ~10 streams per connection (with the 100-conn default).
// Override via env if your workload needs different.
const DEFAULT_API_INFLIGHT_LIMIT = 1000
Expand Down Expand Up @@ -64,21 +66,21 @@ async function buildApiFetcher(options: {
const inflightLimit = options.inflightLimit ?? getApiInflightLimit()

if (!undici) {
return limitConcurrency(fetch, inflightLimit)
return limitConcurrency(withRetry(fetch), inflightLimit)
}

const { Agent, ProxyAgent, fetch: undiciFetch } = undici
const connections = options.connectionLimit ?? getApiConnectionLimit()
const dispatcher = options.proxy
? new ProxyAgent({
uri: options.proxy,
allowH2: true,
connections,
})
uri: options.proxy,
allowH2: true,
connections,
})
: new Agent({
allowH2: true,
connections,
})
allowH2: true,
connections,
})
const fetchWithDispatcher = undiciFetch as unknown as (
input: RequestInfo | URL,
init?: UndiciRequestInit
Expand All @@ -93,7 +95,56 @@ async function buildApiFetcher(options: {
})
}) as typeof fetch

return limitConcurrency(wrapped, inflightLimit)
return limitConcurrency(withRetry(wrapped), inflightLimit)
}

function getRequestRetries(): number {
return parseIntEnv('E2B_REQUEST_RETRIES', DEFAULT_REQUEST_RETRIES)
}

export function withRetry(baseFetch: typeof fetch): typeof fetch {
const maxRetries = getRequestRetries()
if (maxRetries <= 0) return baseFetch

return (async (input: RequestInfo | URL, init?: RequestInit) => {
let lastError: unknown
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await baseFetch(input, init)
if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < maxRetries) {
// Consume the body before retrying
await response.text().catch(() => { })
const delay = Math.min(2 ** attempt * 1000, 8000)
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
console.warn(
`[e2b] Retrying ${init?.method ?? 'GET'} ${url} (attempt ${attempt + 1}/${maxRetries}, backoff ${delay}ms): server returned ${response.status}`
)
await sleep(delay)
continue
}
return response
} catch (error: unknown) {
lastError = error
// Don't retry abort/timeout errors
if (error instanceof DOMException && error.name === 'AbortError') throw error
if (attempt < maxRetries) {
const delay = Math.min(2 ** attempt * 1000, 8000)
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
console.warn(
`[e2b] Retrying ${init?.method ?? 'GET'} ${url} (attempt ${attempt + 1}/${maxRetries}, backoff ${delay}ms): ${error}`
)
await sleep(delay)
continue
}
throw error
}
}
throw lastError
}) as typeof fetch
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

export function getApiConnectionLimit(): number {
Expand Down
167 changes: 167 additions & 0 deletions packages/js-sdk/tests/api/retry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { afterEach, describe, expect, test, vi } from 'vitest'

afterEach(() => {
vi.restoreAllMocks()
vi.resetModules()
delete process.env.E2B_REQUEST_RETRIES
})

describe('withRetry', () => {
test('returns response on success without retrying', async () => {
const { withRetry } = await import('../../src/api/http2')
const baseFetch = vi.fn(() => Promise.resolve(new Response('ok', { status: 200 })))
const retryFetch = withRetry(baseFetch as typeof fetch)

const response = await retryFetch('https://api.e2b.dev/test')

expect(response.status).toBe(200)
expect(baseFetch).toHaveBeenCalledOnce()
})

test('retries on 502 and succeeds', async () => {
const { withRetry } = await import('../../src/api/http2')
const baseFetch = vi
.fn()
.mockResolvedValueOnce(new Response('bad gateway', { status: 502 }))
.mockResolvedValueOnce(new Response('ok', { status: 200 }))
const retryFetch = withRetry(baseFetch as typeof fetch)

const response = await retryFetch('https://api.e2b.dev/test')

expect(response.status).toBe(200)
expect(baseFetch).toHaveBeenCalledTimes(2)
})

test('retries on 503 and succeeds', async () => {
const { withRetry } = await import('../../src/api/http2')
const baseFetch = vi
.fn()
.mockResolvedValueOnce(new Response('unavailable', { status: 503 }))
.mockResolvedValueOnce(new Response('ok', { status: 200 }))
const retryFetch = withRetry(baseFetch as typeof fetch)

const response = await retryFetch('https://api.e2b.dev/test')

expect(response.status).toBe(200)
expect(baseFetch).toHaveBeenCalledTimes(2)
})

test('retries on 504 and succeeds', async () => {
const { withRetry } = await import('../../src/api/http2')
const baseFetch = vi
.fn()
.mockResolvedValueOnce(new Response('timeout', { status: 504 }))
.mockResolvedValueOnce(new Response('ok', { status: 200 }))
const retryFetch = withRetry(baseFetch as typeof fetch)

const response = await retryFetch('https://api.e2b.dev/test')

expect(response.status).toBe(200)
expect(baseFetch).toHaveBeenCalledTimes(2)
})

test('does not retry on 400', async () => {
const { withRetry } = await import('../../src/api/http2')
const baseFetch = vi.fn(() =>
Promise.resolve(new Response('bad request', { status: 400 }))
)
const retryFetch = withRetry(baseFetch as typeof fetch)

const response = await retryFetch('https://api.e2b.dev/test')

expect(response.status).toBe(400)
expect(baseFetch).toHaveBeenCalledOnce()
})

test('does not retry on 401', async () => {
const { withRetry } = await import('../../src/api/http2')
const baseFetch = vi.fn(() =>
Promise.resolve(new Response('unauthorized', { status: 401 }))
)
const retryFetch = withRetry(baseFetch as typeof fetch)

const response = await retryFetch('https://api.e2b.dev/test')

expect(response.status).toBe(401)
expect(baseFetch).toHaveBeenCalledOnce()
})

test('does not retry on 429', async () => {
const { withRetry } = await import('../../src/api/http2')
const baseFetch = vi.fn(() =>
Promise.resolve(new Response('rate limited', { status: 429 }))
)
const retryFetch = withRetry(baseFetch as typeof fetch)

const response = await retryFetch('https://api.e2b.dev/test')

expect(response.status).toBe(429)
expect(baseFetch).toHaveBeenCalledOnce()
})

test('retries on network error and succeeds', async () => {
const { withRetry } = await import('../../src/api/http2')
const baseFetch = vi
.fn()
.mockRejectedValueOnce(new TypeError('fetch failed'))
.mockResolvedValueOnce(new Response('ok', { status: 200 }))
const retryFetch = withRetry(baseFetch as typeof fetch)

const response = await retryFetch('https://api.e2b.dev/test')

expect(response.status).toBe(200)
expect(baseFetch).toHaveBeenCalledTimes(2)
})

test('does not retry on AbortError', async () => {
const { withRetry } = await import('../../src/api/http2')
const abortError = new DOMException('The operation was aborted', 'AbortError')
const baseFetch = vi.fn().mockRejectedValue(abortError)
const retryFetch = withRetry(baseFetch as typeof fetch)

await expect(retryFetch('https://api.e2b.dev/test')).rejects.toThrow(
'The operation was aborted'
)
expect(baseFetch).toHaveBeenCalledOnce()
})

test('exhausts retries on persistent network error', async () => {
process.env.E2B_REQUEST_RETRIES = '2'
const { withRetry } = await import('../../src/api/http2')
const baseFetch = vi.fn().mockRejectedValue(new TypeError('fetch failed'))
const retryFetch = withRetry(baseFetch as typeof fetch)

await expect(retryFetch('https://api.e2b.dev/test')).rejects.toThrow('fetch failed')
// 1 initial + 2 retries = 3 total
expect(baseFetch).toHaveBeenCalledTimes(3)
})

test('returns last retryable response when retries exhausted', async () => {
process.env.E2B_REQUEST_RETRIES = '2'
const { withRetry } = await import('../../src/api/http2')
const baseFetch = vi.fn(() =>
Promise.resolve(new Response('bad gateway', { status: 502 }))
)
const retryFetch = withRetry(baseFetch as typeof fetch)

const response = await retryFetch('https://api.e2b.dev/test')

expect(response.status).toBe(502)
// 1 initial + 2 retries = 3 total
expect(baseFetch).toHaveBeenCalledTimes(3)
})

test('disables retry when E2B_REQUEST_RETRIES=0', async () => {
process.env.E2B_REQUEST_RETRIES = '0'
const { withRetry } = await import('../../src/api/http2')
const baseFetch = vi.fn(() =>
Promise.resolve(new Response('bad gateway', { status: 502 }))
)
const retryFetch = withRetry(baseFetch as typeof fetch)

const response = await retryFetch('https://api.e2b.dev/test')

expect(response.status).toBe(502)
expect(baseFetch).toHaveBeenCalledOnce()
})
})
9 changes: 9 additions & 0 deletions packages/python-sdk/e2b/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ async def on_response(response: Response) -> None:

connection_retries = int(os.getenv("E2B_CONNECTION_RETRIES", "3"))

# Number of times to retry a request when a transient error occurs
# (5xx responses, network/protocol errors like h2 ConnectionTerminated, etc.)
request_retries = int(os.getenv("E2B_REQUEST_RETRIES", "3"))

# Status codes that are safe to retry (server-side transient errors)
RETRYABLE_STATUS_CODES = frozenset({502, 503, 504})

_retry_logger = logging.getLogger("e2b.api.retry")


@dataclass
class SandboxCreateResponse:
Expand Down
35 changes: 34 additions & 1 deletion packages/python-sdk/e2b/api/client_async/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from httpx._types import ProxyTypes

from e2b.api import AsyncApiClient, connection_retries, limits
from e2b.api import AsyncApiClient, connection_retries, limits, request_retries, RETRYABLE_STATUS_CODES, _retry_logger
from e2b.connection_config import ConnectionConfig

TransportKey = Tuple[bool, Optional[ProxyTypes]]
Expand All @@ -33,6 +33,39 @@ class AsyncTransportWithLogger(httpx.AsyncHTTPTransport):
def pool(self):
return self._pool

async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
last_exc: Optional[Exception] = None
for attempt in range(1 + request_retries):
try:
response = await super().handle_async_request(request)
if response.status_code in RETRYABLE_STATUS_CODES and attempt < request_retries:
await response.aread()
await response.aclose()
delay = min(2 ** attempt, 8)
_retry_logger.warning(
"Retrying %s %s (attempt %d/%d, backoff %ds): server returned %d",
request.method, request.url, attempt + 1, request_retries, delay,
response.status_code,
)
await asyncio.sleep(delay)
continue
return response
except httpx.TimeoutException:
raise
except Exception as exc:
last_exc = exc
if attempt < request_retries:
delay = min(2 ** attempt, 8)
_retry_logger.warning(
"Retrying %s %s (attempt %d/%d, backoff %ds): %s",
request.method, request.url, attempt + 1, request_retries, delay,
exc,
)
await asyncio.sleep(delay)
continue
raise
raise last_exc # type: ignore[misc]


def _get_cached_transport(cls, config: ConnectionConfig, http2: bool):
loop = asyncio.get_running_loop()
Expand Down
37 changes: 36 additions & 1 deletion packages/python-sdk/e2b/api/client_sync/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from typing import Dict, Optional, Tuple

import time
import httpx
import threading

from httpx._types import ProxyTypes

from e2b.api import ApiClient, connection_retries, limits
from e2b.api import ApiClient, connection_retries, limits, request_retries, RETRYABLE_STATUS_CODES, _retry_logger
from e2b.connection_config import ConnectionConfig

TransportKey = Tuple[bool, Optional[ProxyTypes]]
Expand All @@ -26,6 +27,40 @@ class TransportWithLogger(httpx.HTTPTransport):
def pool(self):
return self._pool

def handle_request(self, request: httpx.Request) -> httpx.Response:
last_exc: Optional[Exception] = None
for attempt in range(1 + request_retries):
try:
response = super().handle_request(request)
if response.status_code in RETRYABLE_STATUS_CODES and attempt < request_retries:
# Read and close the response before retrying
response.read()
response.close()
delay = min(2 ** attempt, 8)
_retry_logger.warning(
"Retrying %s %s (attempt %d/%d, backoff %ds): server returned %d",
request.method, request.url, attempt + 1, request_retries, delay,
response.status_code,
)
time.sleep(delay)
continue
return response
except httpx.TimeoutException:
raise
except Exception as exc:
last_exc = exc
if attempt < request_retries:
delay = min(2 ** attempt, 8)
_retry_logger.warning(
"Retrying %s %s (attempt %d/%d, backoff %ds): %s",
request.method, request.url, attempt + 1, request_retries, delay,
exc,
)
time.sleep(delay)
continue
raise
raise last_exc # type: ignore[misc]


def get_transport(config: ConnectionConfig, http2: bool = True) -> TransportWithLogger:
instances: Dict[TransportKey, TransportWithLogger] = getattr(
Expand Down
Loading