From 9f813cbd5a11f7b56c2ac23f4b559f6bf94f0f28 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:48:46 +0000 Subject: [PATCH 1/4] Load-balance envd pools by in-flight streams and add a sandbox HTTP/1.1 option Python: replace the fixed CRC32(sandbox_id) shard with EnvdPoolBalancer, a least-loaded picker that opens a further pool only once every open pool carries E2B_ENVD_POOL_STREAMS (90) requests, bounded by E2B_ENVD_POOL_SHARDS (now 16). Envd RPC and HTTP share the balancer; responses are re-wrapped so a request is counted until its body is read or closed. Python + JS: sandbox_http2 / sandboxHttp2 connection option and E2B_SANDBOX_HTTP2 env var pin envd traffic to HTTP/1.1. Co-Authored-By: mish@e2b.dev --- .changeset/envd-pool-growth-http1.md | 8 + packages/js-sdk/README.md | 10 + packages/js-sdk/src/connectionConfig.ts | 17 + packages/js-sdk/src/envd/http2.ts | 20 +- packages/js-sdk/src/sandbox/index.ts | 10 +- packages/js-sdk/src/undici.ts | 19 +- .../js-sdk/tests/connectionConfig.test.ts | 26 ++ packages/js-sdk/tests/envd/http2.test.ts | 57 ++- .../tests/sandbox/envdHttpVersion.test.ts | 60 ++++ packages/python-sdk/README.md | 16 +- packages/python-sdk/e2b/api/__init__.py | 87 ++++- .../e2b/api/client_async/__init__.py | 177 ++++++++-- .../e2b/api/client_sync/__init__.py | 183 ++++++++-- packages/python-sdk/e2b/connection_config.py | 22 ++ .../e2b/envd/client_async/__init__.py | 26 +- .../e2b/envd/client_sync/__init__.py | 32 +- .../tests/test_api_client_transport.py | 331 +++++++++++++++--- .../tests/test_connection_config.py | 27 ++ .../python-sdk/tests/test_env_var_parsing.py | 18 +- .../tests/test_envd_client_transport.py | 25 +- .../tests/test_envd_stream_capacity.py | 177 ++++++---- packages/python-sdk/tests/transport_caches.py | 2 + 22 files changed, 1071 insertions(+), 279 deletions(-) create mode 100644 .changeset/envd-pool-growth-http1.md create mode 100644 packages/js-sdk/tests/sandbox/envdHttpVersion.test.ts diff --git a/.changeset/envd-pool-growth-http1.md b/.changeset/envd-pool-growth-http1.md new file mode 100644 index 0000000000..c9e80459a7 --- /dev/null +++ b/.changeset/envd-pool-growth-http1.md @@ -0,0 +1,8 @@ +--- +'e2b': minor +'@e2b/python-sdk': minor +--- + +Add a `sandbox_http2` (Python) / `sandboxHttp2` (JS) connection option, also settable with the `E2B_SANDBOX_HTTP2` environment variable, to pin sandbox traffic (commands, filesystem, PTY) to HTTP/1.1. Requests to the E2B API are unaffected. + +The Python SDK now spreads sandbox traffic across HTTP/2 connection pools by load instead of hashing each sandbox to one of four fixed pools: every request goes to the pool with the fewest requests in flight, and a further pool is opened only once every open pool carries `E2B_ENVD_POOL_STREAMS` (default 90) requests — below the 100 concurrent streams the sandbox host allows per connection — up to `E2B_ENVD_POOL_SHARDS` (default raised from 4 to 16) pools. A process running a single sandbox keeps one connection; one with hundreds of long-running commands is no longer queued behind a saturated connection, whether they belong to many sandboxes or to a few. diff --git a/packages/js-sdk/README.md b/packages/js-sdk/README.md index d4d2303f17..5911e2fbbe 100644 --- a/packages/js-sdk/README.md +++ b/packages/js-sdk/README.md @@ -66,6 +66,16 @@ const paginator = Sandbox.list() Per-call options still take precedence over the client's options, and clients are isolated from each other and from the env-configured top-level exports. +### Sandbox transport + +In Node, sandbox `commands`, `files` and `pty` traffic goes through a bounded pool of HTTP/2 connections (`E2B_ENVD_RPC_CONNECTIONS`, default `200`, for streams). To avoid HTTP/2 for sandbox traffic altogether — for example when an intermediary on the path retires long-lived HTTP/2 connections — pin it to HTTP/1.1. Requests to the E2B API are unaffected: + +```ts +const sandbox = await Sandbox.create({ sandboxHttp2: false }) +``` + +or, for the whole process, `E2B_SANDBOX_HTTP2=false`. + ### 5. Code execution with Code Interpreter If you need [`runCode()`](https://docs.e2b.dev/code-interpreting/analyze-data-with-ai?utm_source=npm&utm_medium=referral&utm_campaign=readme&utm_content=e2b), install the [Code Interpreter SDK](https://github.com/e2b-dev/code-interpreter): diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index a9ef7fdf84..712227efd3 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -88,6 +88,17 @@ export interface ConnectionOpts { * @example 'http://user:pass@127.0.0.1:8080' */ proxy?: string + /** + * Whether requests to the sandbox (commands, filesystem, PTY) may use + * HTTP/2. Set to `false` to pin them to HTTP/1.1, which uses one connection + * per concurrent request instead of multiplexing streams over shared + * connections — for example when an intermediary on the path retires or + * mishandles long-lived HTTP/2 connections. Does not affect requests to the + * E2B API. Only applies in Node. + * + * @default E2B_SANDBOX_HTTP2 // environment variable or `true` + */ + sandboxHttp2?: boolean /** * Additional headers to send with E2B API requests. @@ -440,6 +451,7 @@ export class ConnectionConfig { readonly requestSource?: string readonly proxy?: string + readonly sandboxHttp2: boolean constructor(opts?: ConnectionOpts) { this.apiKey = opts?.apiKey || ConnectionConfig.apiKey @@ -453,6 +465,7 @@ export class ConnectionConfig { this.headers = { ...(opts?.headers ?? {}), ...(opts?.apiHeaders ?? {}) } ConnectionConfig.applyUserAgent(this.headers, this.requestSource) this.proxy = opts?.proxy + this.sandboxHttp2 = opts?.sandboxHttp2 ?? ConnectionConfig.sandboxHttp2 this.apiUrl = opts?.apiUrl || @@ -511,6 +524,10 @@ export class ConnectionConfig { return getEnvVar('E2B_SANDBOX_URL') } + private static get sandboxHttp2() { + return (getEnvVar('E2B_SANDBOX_HTTP2') || 'true').toLowerCase() !== 'false' + } + private static get debug() { return (getEnvVar('E2B_DEBUG') || 'false').toLowerCase() === 'true' } diff --git a/packages/js-sdk/src/envd/http2.ts b/packages/js-sdk/src/envd/http2.ts index db674df3cd..87b013dd4d 100644 --- a/packages/js-sdk/src/envd/http2.ts +++ b/packages/js-sdk/src/envd/http2.ts @@ -10,11 +10,12 @@ type EnvdFetchOptions = { connectionLimit?: number inflightLimit?: number proxy?: string + http2?: boolean loadUndici?: () => Promise } -// Fetchers are cached per proxy so requests without a proxy keep sharing a -// single dispatcher while each distinct proxy URL gets its own. +// Fetchers are cached per proxy and HTTP version so requests without a proxy +// keep sharing a single dispatcher while each distinct proxy URL gets its own. const envdFetchers = new Map() const envdRpcFetchers = new Map() const DEFAULT_ENVD_CONNECTION_LIMIT = 10 @@ -31,13 +32,18 @@ export function createEnvdFetchForRuntime( connections: options.connectionLimit ?? DEFAULT_ENVD_CONNECTION_LIMIT, inflightLimit: options.inflightLimit ?? 0, proxy: options.proxy, + http2: options.http2, loadUndici: options.loadUndici, }) ) } -export function createEnvdFetch(proxy?: string): typeof fetch { - const key = proxy ?? '' +function fetcherKey(proxy: string | undefined, http2: boolean): string { + return `${http2 ? 'h2' : 'h1'}:${proxy ?? ''}` +} + +export function createEnvdFetch(proxy?: string, http2 = true): typeof fetch { + const key = fetcherKey(proxy, http2) const cached = envdFetchers.get(key) if (cached) { @@ -49,14 +55,15 @@ export function createEnvdFetch(proxy?: string): typeof fetch { const envdFetch = createEnvdFetchForRuntime(runtime, { inflightLimit: getEnvdInflightLimit(), proxy, + http2, }) envdFetchers.set(key, envdFetch) return envdFetch } -export function createEnvdRpcFetch(proxy?: string): typeof fetch { - const key = proxy ?? '' +export function createEnvdRpcFetch(proxy?: string, http2 = true): typeof fetch { + const key = fetcherKey(proxy, http2) const cached = envdRpcFetchers.get(key) if (cached) { @@ -67,6 +74,7 @@ export function createEnvdRpcFetch(proxy?: string): typeof fetch { connectionLimit: getEnvdRpcConnectionLimit(), inflightLimit: getEnvdRpcInflightLimit(), proxy, + http2, }) envdRpcFetchers.set(key, envdRpcFetch) diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index f9964e2b16..3685131422 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -161,8 +161,14 @@ export class Sandbox extends SandboxApi { 'E2b-Sandbox-Id': this.sandboxId, 'E2b-Sandbox-Port': this.envdPort.toString(), } - const envdFetch = createEnvdFetch(this.connectionConfig.proxy) - const envdRpcFetch = createEnvdRpcFetch(this.connectionConfig.proxy) + const envdFetch = createEnvdFetch( + this.connectionConfig.proxy, + this.connectionConfig.sandboxHttp2 + ) + const envdRpcFetch = createEnvdRpcFetch( + this.connectionConfig.proxy, + this.connectionConfig.sandboxHttp2 + ) const rpcTransport = createConnectTransport({ baseUrl: this.envdApiUrl, diff --git a/packages/js-sdk/src/undici.ts b/packages/js-sdk/src/undici.ts index 74a2fd102c..fe976e0a71 100644 --- a/packages/js-sdk/src/undici.ts +++ b/packages/js-sdk/src/undici.ts @@ -10,10 +10,10 @@ type UndiciRequestInit = RequestInit & { } export type UndiciModule = { - Agent: new (options: { allowH2: true; connections?: number }) => unknown + Agent: new (options: { allowH2: boolean; connections?: number }) => unknown ProxyAgent: new (options: { uri: string - allowH2: true + allowH2: boolean connections?: number proxyTunnel: true }) => unknown @@ -87,15 +87,17 @@ export function createRuntimeFetch( } /** - * Build a fetch bound to a bounded undici dispatcher (HTTP/2 enabled, - * `connections` origin connections, optional proxy tunnel), capped at - * `inflightLimit` in-flight requests (`0` disables the cap). Falls back to - * the global fetch — still capped — when undici cannot be loaded. + * Build a fetch bound to a bounded undici dispatcher (HTTP/2 enabled unless + * `http2` is `false`, `connections` origin connections, optional proxy + * tunnel), capped at `inflightLimit` in-flight requests (`0` disables the + * cap). Falls back to the global fetch — still capped — when undici cannot be + * loaded. */ export async function buildDispatchedFetch(options: { connections: number inflightLimit: number proxy?: string + http2?: boolean loadUndici?: () => Promise }): Promise { const undici = await (options.loadUndici ?? loadUndici)() @@ -105,15 +107,16 @@ export async function buildDispatchedFetch(options: { } const { Agent, ProxyAgent, fetch: undiciFetch } = undici + const allowH2 = options.http2 ?? true const dispatcher = options.proxy ? new ProxyAgent({ uri: options.proxy, - allowH2: true, + allowH2, connections: options.connections, proxyTunnel: true, }) : new Agent({ - allowH2: true, + allowH2, connections: options.connections, }) const fetchWithDispatcher = undiciFetch as unknown as ( diff --git a/packages/js-sdk/tests/connectionConfig.test.ts b/packages/js-sdk/tests/connectionConfig.test.ts index 8030249a6b..a635c5f05d 100644 --- a/packages/js-sdk/tests/connectionConfig.test.ts +++ b/packages/js-sdk/tests/connectionConfig.test.ts @@ -15,6 +15,7 @@ beforeEach(() => { E2B_API_URL: process.env.E2B_API_URL, E2B_DOMAIN: process.env.E2B_DOMAIN, E2B_SANDBOX_URL: process.env.E2B_SANDBOX_URL, + E2B_SANDBOX_HTTP2: process.env.E2B_SANDBOX_HTTP2, E2B_DEBUG: process.env.E2B_DEBUG, E2B_USER_AGENT_SOURCE: process.env.E2B_USER_AGENT_SOURCE, } @@ -164,6 +165,31 @@ test('sandbox_url stays localhost in debug mode', () => { ) }) +test('sandboxHttp2 defaults to true and reads E2B_SANDBOX_HTTP2', () => { + delete process.env.E2B_SANDBOX_HTTP2 + assert.equal(new ConnectionConfig().sandboxHttp2, true) + assert.equal( + new ConnectionConfig({ sandboxHttp2: false }).sandboxHttp2, + false + ) + + process.env.E2B_SANDBOX_HTTP2 = 'false' + assert.equal(new ConnectionConfig().sandboxHttp2, false) + process.env.E2B_SANDBOX_HTTP2 = 'FALSE' + assert.equal(new ConnectionConfig().sandboxHttp2, false) + process.env.E2B_SANDBOX_HTTP2 = 'true' + assert.equal(new ConnectionConfig().sandboxHttp2, true) +}) + +test('sandboxHttp2 in args has priority over env var', () => { + process.env.E2B_SANDBOX_HTTP2 = 'false' + assert.equal(new ConnectionConfig({ sandboxHttp2: true }).sandboxHttp2, true) + + // Per-call options and bound options keep the flag when merged. + const merged = ConnectionConfig.mergeOpts({ sandboxHttp2: false }, {}) + assert.equal(new ConnectionConfig(merged).sandboxHttp2, false) +}) + test('debug false in args overrides E2B_DEBUG env var', () => { process.env.E2B_DEBUG = 'true' diff --git a/packages/js-sdk/tests/envd/http2.test.ts b/packages/js-sdk/tests/envd/http2.test.ts index ed553f8880..3cfbd16628 100644 --- a/packages/js-sdk/tests/envd/http2.test.ts +++ b/packages/js-sdk/tests/envd/http2.test.ts @@ -89,23 +89,78 @@ test('uses a ProxyAgent dispatcher when a proxy is configured', async () => { expect(requests[0].init?.dispatcher).toBeInstanceOf(ProxyAgent) }) -test('caches envd fetchers per proxy', async () => { +test('pins the dispatcher to HTTP/1.1 when http2 is false', async () => { + const agents: Array<{ allowH2?: boolean; connections?: number }> = [] + const proxyAgents: Array<{ uri?: string; allowH2?: boolean }> = [] + + class Agent { + constructor(options: { allowH2?: boolean; connections?: number }) { + agents.push(options) + } + } + + class ProxyAgent { + constructor(options: { uri?: string; allowH2?: boolean }) { + proxyAgents.push(options) + } + } + + const undiciFetch = vi.fn(() => Promise.resolve(new Response('ok'))) + + const { createEnvdFetchForRuntime } = await import('../../src/envd/http2') + + const direct = createEnvdFetchForRuntime('node', { + connectionLimit: 1, + http2: false, + loadUndici: () => + Promise.resolve({ Agent, ProxyAgent, fetch: undiciFetch }), + }) + await direct('https://example.com/status') + + const proxied = createEnvdFetchForRuntime('node', { + connectionLimit: 1, + http2: false, + proxy: 'http://127.0.0.1:8080', + loadUndici: () => + Promise.resolve({ Agent, ProxyAgent, fetch: undiciFetch }), + }) + await proxied('https://example.com/status') + + expect(agents).toEqual([{ allowH2: false, connections: 1 }]) + expect(proxyAgents).toEqual([ + { + uri: 'http://127.0.0.1:8080', + allowH2: false, + connections: 1, + proxyTunnel: true, + }, + ]) +}) + +test('caches envd fetchers per proxy and HTTP version', async () => { const { createEnvdFetch, createEnvdRpcFetch } = await import('../../src/envd/http2') const noProxy = createEnvdFetch() const proxyA = createEnvdFetch('http://127.0.0.1:8080') + const noProxyH1 = createEnvdFetch(undefined, false) expect(createEnvdFetch()).toBe(noProxy) + expect(createEnvdFetch(undefined, true)).toBe(noProxy) expect(createEnvdFetch('http://127.0.0.1:8080')).toBe(proxyA) + expect(createEnvdFetch(undefined, false)).toBe(noProxyH1) expect(proxyA).not.toBe(noProxy) + expect(noProxyH1).not.toBe(noProxy) const rpcNoProxy = createEnvdRpcFetch() const rpcProxyA = createEnvdRpcFetch('http://127.0.0.1:8080') + const rpcNoProxyH1 = createEnvdRpcFetch(undefined, false) expect(createEnvdRpcFetch()).toBe(rpcNoProxy) expect(createEnvdRpcFetch('http://127.0.0.1:8080')).toBe(rpcProxyA) + expect(createEnvdRpcFetch(undefined, false)).toBe(rpcNoProxyH1) expect(rpcProxyA).not.toBe(rpcNoProxy) + expect(rpcNoProxyH1).not.toBe(rpcNoProxy) }) test('passes Request objects to undici as URL plus init', async () => { diff --git a/packages/js-sdk/tests/sandbox/envdHttpVersion.test.ts b/packages/js-sdk/tests/sandbox/envdHttpVersion.test.ts new file mode 100644 index 0000000000..2d486e6a75 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/envdHttpVersion.test.ts @@ -0,0 +1,60 @@ +import { afterEach, assert, test, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + createEnvdFetch: vi.fn(() => vi.fn()), + createEnvdRpcFetch: vi.fn(() => vi.fn()), +})) + +vi.mock('@connectrpc/connect-web', () => ({ + createConnectTransport: vi.fn(() => ({})), +})) + +vi.mock('../../src/envd/http2', () => ({ + createEnvdFetch: mocks.createEnvdFetch, + createEnvdRpcFetch: mocks.createEnvdRpcFetch, +})) + +afterEach(() => { + vi.clearAllMocks() + delete process.env.E2B_SANDBOX_HTTP2 +}) + +async function createSandbox(opts: { sandboxHttp2?: boolean; proxy?: string }) { + const { ConnectionConfig, Sandbox } = await import('../../src') + const config = new ConnectionConfig(opts) + return new Sandbox({ + ...config, + sandboxId: 'sbx-test', + sandboxDomain: 'sandbox.e2b.dev', + envdVersion: '0.2.4', + envdAccessToken: 'tok', + }) +} + +test('envd fetchers default to HTTP/2', async () => { + await createSandbox({}) + + assert.deepEqual(mocks.createEnvdFetch.mock.calls[0], [undefined, true]) + assert.deepEqual(mocks.createEnvdRpcFetch.mock.calls[0], [undefined, true]) +}) + +test('sandboxHttp2: false pins envd HTTP and RPC fetchers to HTTP/1.1', async () => { + await createSandbox({ sandboxHttp2: false, proxy: 'http://127.0.0.1:8080' }) + + assert.deepEqual(mocks.createEnvdFetch.mock.calls[0], [ + 'http://127.0.0.1:8080', + false, + ]) + assert.deepEqual(mocks.createEnvdRpcFetch.mock.calls[0], [ + 'http://127.0.0.1:8080', + false, + ]) +}) + +test('E2B_SANDBOX_HTTP2=false pins envd fetchers to HTTP/1.1', async () => { + process.env.E2B_SANDBOX_HTTP2 = 'false' + await createSandbox({}) + + assert.deepEqual(mocks.createEnvdFetch.mock.calls[0], [undefined, false]) + assert.deepEqual(mocks.createEnvdRpcFetch.mock.calls[0], [undefined, false]) +}) diff --git a/packages/python-sdk/README.md b/packages/python-sdk/README.md index 6997acd541..c18c26dfd9 100644 --- a/packages/python-sdk/README.md +++ b/packages/python-sdk/README.md @@ -68,14 +68,24 @@ Per-call params still take precedence over the client's params, and clients are ### High-concurrency sandbox streams -The Python SDK spreads sandbox `commands` and `files` traffic across four HTTP/2 connection pools by default. This prevents long-running streams in one process from all contending for a single connection's concurrent-stream limit. +The sandbox host allows 100 concurrent streams per HTTP/2 connection, and a long-running command holds its stream for its whole lifetime. The Python SDK therefore spreads sandbox `commands`, `files` and `pty` traffic across HTTP/2 connection pools by load: each request goes to the pool with the fewest requests in flight, and a further pool (connection) is opened only once every open pool carries `E2B_ENVD_POOL_STREAMS` requests (default `90`), up to `E2B_ENVD_POOL_SHARDS` pools (default `16`). A process running a few sandboxes keeps a single connection; one running hundreds of concurrent commands is never queued behind a saturated connection. -If one process needs more capacity, set `E2B_ENVD_POOL_SHARDS` before importing `e2b`. Each additional shard can open another connection to the sandbox host, so increase it only as needed: +If one process needs more than `16 × 90` concurrent sandbox streams, raise the pool bound before importing `e2b`: ```sh -E2B_ENVD_POOL_SHARDS=8 python eval.py +E2B_ENVD_POOL_SHARDS=32 python eval.py ``` +To avoid HTTP/2 for sandbox traffic altogether — for example when an intermediary on the path retires long-lived HTTP/2 connections — pin it to HTTP/1.1, which uses one connection per concurrent request. Requests to the E2B API are unaffected: + +```py +from e2b import Sandbox + +sandbox = Sandbox.create(sandbox_http2=False) +``` + +or, for the whole process, `E2B_SANDBOX_HTTP2=false`. + ### 5. Code execution with Code Interpreter If you need [`run_code()`](https://docs.e2b.dev/code-interpreting/analyze-data-with-ai?utm_source=pypi&utm_medium=referral&utm_campaign=readme&utm_content=e2b), install the [Code Interpreter SDK](https://github.com/e2b-dev/code-interpreter): diff --git a/packages/python-sdk/e2b/api/__init__.py b/packages/python-sdk/e2b/api/__init__.py index 845658ccfb..76f7c1c640 100644 --- a/packages/python-sdk/e2b/api/__init__.py +++ b/packages/python-sdk/e2b/api/__init__.py @@ -1,10 +1,10 @@ import json import logging import os -import zlib +import threading from dataclasses import dataclass from types import TracebackType -from typing import NamedTuple, Optional, Protocol, Tuple, Union +from typing import List, NamedTuple, Optional, Protocol, Tuple, Union from urllib.parse import quote import httpx @@ -103,26 +103,79 @@ async def on_response(response: Response) -> None: # is no longer read. pool_idle_timeout = float(os.getenv("E2B_KEEPALIVE_EXPIRY") or "300") pool_max_idle_per_host = int(os.getenv("E2B_MAX_KEEPALIVE_CONNECTIONS") or "20") -envd_pool_shards = max(1, int(os.getenv("E2B_ENVD_POOL_SHARDS") or "4")) +# Envd (sandbox) traffic is spread over connection pools opened on demand, see +# `EnvdPoolBalancer`: at most `envd_pool_shards` pools, a further one opened +# once every open pool carries `envd_pool_streams` streams. The stream bound +# sits below the 100 concurrent streams the sandbox host advertises per HTTP/2 +# connection, so a pool is left before the peer limit starts queueing. +envd_pool_shards = max(1, int(os.getenv("E2B_ENVD_POOL_SHARDS") or "16")) +envd_pool_streams = max(1, int(os.getenv("E2B_ENVD_POOL_STREAMS") or "90")) -def envd_pool_shard(config: ConnectionConfig) -> int: - """Return the stable connection-pool shard for a sandbox's envd traffic. +class EnvdPoolBalancer: + """Least-loaded selection among a bounded set of envd connection pools. Production envd requests share one origin, whose HTTP/2 connection has a - finite concurrent-stream limit. Long-running commands hold those streams, - so one process-wide connection becomes a bottleneck even though the edge - and account can run more sandboxes. A small bounded set of pools provides - additional connections without returning to one connection per sandbox. - - The sandbox ID is carried on every envd request and CRC32 is stable across - processes, unlike Python's randomized ``hash``. Configs without a sandbox - ID (including control-plane clients) stay on shard zero. + finite concurrent-stream limit. Long-running commands hold their streams + for their whole lifetime, so a saturated connection queues every further + request — a readiness check as much as the next command — behind them, + while the edge and account could serve far more. Spreading sandboxes over + a fixed number of connections by hashing only moves the ceiling, and opens + every connection even for a process running a single sandbox. + + Each request is sent on the pool with the fewest requests in flight. A new + pool is opened only when every open pool already carries + ``streams_per_pool`` requests, up to ``max_pools``; past that the + least-loaded pool takes the request regardless. Ties go to the lowest + index, so load concentrates on the first pools and later ones fall idle + and expire once a burst is over. + + A request is in flight from the moment it is sent until its response body + has been fully read or closed. Streamed responses (a running command's + output) are therefore counted for their whole lifetime, unary ones only + briefly. Thread-safe: the sync stack shares one balancer across threads. """ - sandbox_id = config.sandbox_headers.get("E2b-Sandbox-Id") - if not sandbox_id: - return 0 - return zlib.crc32(sandbox_id.encode()) % envd_pool_shards + + def __init__(self, max_pools: int, streams_per_pool: int): + self._max_pools = max(1, max_pools) + self._streams_per_pool = max(1, streams_per_pool) + self._active: List[int] = [] + self._lock = threading.Lock() + + @property + def active_streams(self) -> Tuple[int, ...]: + """Requests in flight per open pool, by pool index.""" + with self._lock: + return tuple(self._active) + + def acquire(self) -> int: + """Pick the pool for a new request and count it in flight there. The + caller must pair it with exactly one :meth:`release` of the index.""" + with self._lock: + if self._active: + index = min(range(len(self._active)), key=self._active.__getitem__) + if ( + self._active[index] < self._streams_per_pool + or len(self._active) >= self._max_pools + ): + self._active[index] += 1 + return index + self._active.append(1) + return len(self._active) - 1 + + def release(self, index: int) -> None: + with self._lock: + self._active[index] -= 1 + + +def envd_pool_balancer(http2: bool) -> EnvdPoolBalancer: + """The balancer for one set of envd pools, sized from the environment. + HTTP/1.1 has no per-connection stream limit to spread over — the pool + opens a connection per concurrent request — so it gets a single pool.""" + return EnvdPoolBalancer( + envd_pool_shards if http2 else 1, + envd_pool_streams, + ) class ProxyConfig(NamedTuple): diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index c549e36d66..f21530b5fe 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -1,18 +1,19 @@ import threading -from typing import Dict, Optional, Tuple, Union +from typing import AsyncIterator, Callable, Dict, Optional, Tuple, Union import httpx -from pyqwest import HTTPTransport, HTTPVersion, Request, Response +from pyqwest import HTTPTransport, HTTPVersion, Request, Response, Transport from pyqwest.httpx import AsyncPyqwestTransport from pyqwest.middleware.retry import RetryMode, RetryTransport from e2b.retry import AsyncRetryableTransport from e2b.api import ( AsyncApiClient, + EnvdPoolBalancer, ProxyConfig, connection_retries, - envd_pool_shard, + envd_pool_balancer, make_async_logging_event_hooks, pool_idle_timeout, pool_max_idle_per_host, @@ -54,6 +55,73 @@ def should_retry_response( return isinstance(response, ConnectionError) +class _TrackedContent: + """A pooled response's body, counted in flight on its pool until it is + fully read, fails, or is closed — whichever comes first, released once. + Closing closes the pooled response, which is what cancels an HTTP/2 stream + a consumer abandons early (see :func:`e2b.envd.client_async.as_stream`).""" + + def __init__(self, response: Response, release: Callable[[], None]): + self._response = response + self._content = response.content + self._release: Optional[Callable[[], None]] = release + + def __aiter__(self) -> AsyncIterator[Union[bytes, bytearray, memoryview]]: + return self + + async def __anext__(self) -> Union[bytes, bytearray, memoryview]: + try: + return await self._content.__anext__() + except BaseException: + self._settle() + raise + + async def aclose(self) -> None: + self._settle() + await self._response.aclose() + + def _settle(self) -> None: + release, self._release = self._release, None + if release is not None: + release() + + +class EnvdPoolTransport: + """The envd transport: a set of connection pools opened on demand, each + request sent on the least-loaded one (see + :class:`e2b.api.EnvdPoolBalancer`). Pools are the cached transports of + :func:`get_pyqwest_transport`, so each brings the connect-only retries. + + The response is re-wrapped so the body's lifetime — not just the response + head — is what holds the pool's slot: a running command's stream stays + counted until it ends. pyqwest responses cannot be subclassed, so the + wrapper is a fresh ``Response`` over the original head, body and (shared, + filled on completion) trailers.""" + + def __init__( + self, + open_pool: Callable[[int], Transport], + balancer: EnvdPoolBalancer, + ): + self.open_pool = open_pool + self.balancer = balancer + + async def execute(self, request: Request) -> Response: + index = self.balancer.acquire() + try: + response = await self.open_pool(index).execute(request) + except BaseException: + self.balancer.release(index) + raise + return Response( + status=response.status, + http_version=response.http_version, + headers=response.headers, + content=_TrackedContent(response, lambda: self.balancer.release(index)), + trailers=response.trailers, + ) + + _TransportKey = Tuple[Optional[ProxyConfig], Optional[float], bool, int] """Cache key: proxy, idle read bound, HTTP version, connection-pool shard. @@ -61,11 +129,16 @@ def should_retry_response( allows bounded parallel HTTP/2 connections to the stable envd host. Each distinct combination is necessarily its own pool.""" +_EnvdPoolKey = Tuple[Optional[ProxyConfig], Optional[float], bool] +"""Cache key of the envd transports: a :class:`_TransportKey` without the +shard, which the balancer picks per request.""" + _transport_lock = threading.Lock() # One pyqwest transport — one reqwest connection pool — per key; a `None` proxy -# is the direct pool. Generic API and volume traffic use shard zero. Envd RPC -# and non-streaming HTTP traffic for one sandbox use the same sandbox shard, so -# they share one HTTP/2 connection instead of opening one per stack. +# is the direct pool. Generic API and volume traffic use shard zero. Envd +# traffic goes through `EnvdPoolTransport`, which opens further shards as the +# ones in use fill up; envd RPC and HTTP share those, so a process with light +# traffic keeps a single connection to the sandbox host. # # pyqwest's I/O runs on its own Rust runtime, so unlike the httpx transports # they replaced, the transports are not bound to an event loop and the caches @@ -73,6 +146,8 @@ def should_retry_response( _transports: Dict[_TransportKey, ConnectionRetryTransport] = {} # The httpx adapter over each pool, shared by every httpx client on it. _httpx_transports: Dict[_TransportKey, AsyncPyqwestTransport] = {} +_envd_transports: Dict[_EnvdPoolKey, EnvdPoolTransport] = {} +_envd_httpx_transports: Dict[_EnvdPoolKey, AsyncPyqwestTransport] = {} def get_pyqwest_transport( @@ -133,16 +208,15 @@ def get_httpx_transport( proxy: Optional[ProxyConfig], read_timeout: Optional[float] = None, http2: bool = True, - pool_shard: int = 0, ) -> AsyncPyqwestTransport: """The httpx adapter over the shared pool of :func:`get_pyqwest_transport`, for the generated httpx clients (control - plane, envd HTTP API, volume content). The adapter holds no state of its - own and does not close the pool, so closing an httpx client leaves the - pool intact for the other clients on it.""" - key = (proxy, read_timeout, http2, pool_shard) + plane, volume content). The adapter holds no state of its own and does not + close the pool, so closing an httpx client leaves the pool intact for the + other clients on it.""" + key = (proxy, read_timeout, http2, 0) # Resolve the pool before taking the lock: it takes the same one. - pool = get_pyqwest_transport(proxy, read_timeout, http2, pool_shard) + pool = get_pyqwest_transport(proxy, read_timeout, http2) with _transport_lock: transport = _httpx_transports.get(key) if transport is None: @@ -151,18 +225,55 @@ def get_httpx_transport( return transport +def get_envd_pyqwest_transport( + proxy: Optional[ProxyConfig], + read_timeout: Optional[float] = None, + http2: bool = True, +) -> EnvdPoolTransport: + """The shared envd transport for the given tuning: the envd RPC clients + take it directly, the envd HTTP API through :func:`get_envd_httpx_transport`, + so both draw on the same pools and load counts. Its pools are the shards + of :func:`get_pyqwest_transport` with the same tuning, sized by + :func:`e2b.api.envd_pool_balancer`.""" + key = (proxy, read_timeout, http2) + with _transport_lock: + transport = _envd_transports.get(key) + if transport is None: + transport = EnvdPoolTransport( + lambda shard: get_pyqwest_transport(proxy, read_timeout, http2, shard), + envd_pool_balancer(http2), + ) + _envd_transports[key] = transport + return transport + + +def get_envd_httpx_transport( + proxy: Optional[ProxyConfig], + read_timeout: Optional[float] = None, + http2: bool = True, +) -> AsyncPyqwestTransport: + """The httpx adapter over :func:`get_envd_pyqwest_transport`, for the + generated envd HTTP API clients.""" + key = (proxy, read_timeout, http2) + pool = get_envd_pyqwest_transport(proxy, read_timeout, http2) + with _transport_lock: + transport = _envd_httpx_transports.get(key) + if transport is None: + transport = AsyncPyqwestTransport(pool) + _envd_httpx_transports[key] = transport + return transport + + def get_transport( config: ConnectionConfig, http2: bool = True, *, for_streaming: bool = False, - pool_shard: int = 0, ) -> 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 shared httpx transport factory for the control-plane REST API; + :func:`get_envd_transport` is its counterpart for the envd HTTP API. For + TLS connections ALPN negotiates the HTTP version (HTTP/2 against the E2B + API), like the http2-enabled httpx transport this replaced. ``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: @@ -183,29 +294,27 @@ def get_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 + config: ConnectionConfig, + http2: Optional[bool] = None, + *, + for_streaming: bool = False, ) -> AsyncPyqwestTransport: - """The envd HTTP API's transport, sharded by sandbox ID. + """The envd HTTP API's transport (file transfers, health checks), on the + load-balanced envd pools rather than the control plane's single pool. - Envd RPC and non-streaming HTTP traffic for one sandbox resolve the same - shard, retaining their shared connection while spreading different - sandboxes over a bounded number of connections to the stable sandbox host. - Streaming HTTP traffic uses the same shard number with a separate - 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. + ``http2`` defaults to the config's ``sandbox_http2`` option; ``False`` + pins the envd traffic to HTTP/1.1 (see :func:`get_transport` for what + that changes). ``for_streaming`` selects the read-timeout-keyed pools, + as for :func:`get_transport`. """ - return get_transport( - config, - http2, - for_streaming=for_streaming, - pool_shard=envd_pool_shard(config), + return get_envd_httpx_transport( + proxy_to_config(config.proxy), + READ_TIMEOUT if for_streaming else None, + config.sandbox_http2 if http2 is None else http2, ) diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index 289c8bdfcf..662ada3ae7 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -1,18 +1,25 @@ -from typing import Dict, Optional, Tuple, Union +from typing import Callable, Dict, Iterator, Optional, Tuple, Union import httpx import threading -from pyqwest import HTTPVersion, SyncHTTPTransport, SyncRequest, SyncResponse +from pyqwest import ( + HTTPVersion, + SyncHTTPTransport, + SyncRequest, + SyncResponse, + SyncTransport, +) from pyqwest.httpx import PyqwestTransport from pyqwest.middleware.retry import RetryMode, SyncRetryTransport from e2b.retry import RetryableTransport from e2b.api import ( ApiClient, + EnvdPoolBalancer, ProxyConfig, connection_retries, - envd_pool_shard, + envd_pool_balancer, make_logging_event_hooks, pool_idle_timeout, pool_max_idle_per_host, @@ -54,6 +61,73 @@ def should_retry_response( return isinstance(response, ConnectionError) +class _TrackedContent: + """A pooled response's body, counted in flight on its pool until it is + fully read, fails, or is closed — whichever comes first, released once. + Closing closes the pooled response, which is what cancels an HTTP/2 stream + a consumer abandons early (see :func:`e2b.envd.client_sync.as_stream`).""" + + def __init__(self, response: SyncResponse, release: Callable[[], None]): + self._response = response + self._content = iter(response.content) + self._release: Optional[Callable[[], None]] = release + + def __iter__(self) -> Iterator[Union[bytes, bytearray, memoryview]]: + return self + + def __next__(self) -> Union[bytes, bytearray, memoryview]: + try: + return next(self._content) + except BaseException: + self._settle() + raise + + def close(self) -> None: + self._settle() + self._response.close() + + def _settle(self) -> None: + release, self._release = self._release, None + if release is not None: + release() + + +class EnvdPoolTransport: + """The envd transport: a set of connection pools opened on demand, each + request sent on the least-loaded one (see + :class:`e2b.api.EnvdPoolBalancer`). Pools are the cached transports of + :func:`get_pyqwest_transport`, so each brings the connect-only retries. + + The response is re-wrapped so the body's lifetime — not just the response + head — is what holds the pool's slot: a running command's stream stays + counted until it ends. pyqwest responses cannot be subclassed, so the + wrapper is a fresh ``SyncResponse`` over the original head, body and + (shared, filled on completion) trailers.""" + + def __init__( + self, + open_pool: Callable[[int], SyncTransport], + balancer: EnvdPoolBalancer, + ): + self.open_pool = open_pool + self.balancer = balancer + + def execute_sync(self, request: SyncRequest) -> SyncResponse: + index = self.balancer.acquire() + try: + response = self.open_pool(index).execute_sync(request) + except BaseException: + self.balancer.release(index) + raise + return SyncResponse( + status=response.status, + http_version=response.http_version, + headers=response.headers, + content=_TrackedContent(response, lambda: self.balancer.release(index)), + trailers=response.trailers, + ) + + _TransportKey = Tuple[Optional[ProxyConfig], Optional[float], bool, int] """Cache key: proxy, idle read bound, HTTP version, connection-pool shard. @@ -61,17 +135,24 @@ def should_retry_response( allows bounded parallel HTTP/2 connections to the stable envd host. Each distinct combination is necessarily its own pool.""" +_EnvdPoolKey = Tuple[Optional[ProxyConfig], Optional[float], bool] +"""Cache key of the envd transports: a :class:`_TransportKey` without the +shard, which the balancer picks per request.""" + _transport_lock = threading.Lock() # One pyqwest transport — one reqwest connection pool — per key; a `None` proxy -# is the direct pool. Generic API and volume traffic use shard zero. Envd RPC -# and non-streaming HTTP traffic for one sandbox use the same sandbox shard, so -# they share one HTTP/2 connection instead of opening one per stack. +# is the direct pool. Generic API and volume traffic use shard zero. Envd +# traffic goes through `EnvdPoolTransport`, which opens further shards as the +# ones in use fill up; envd RPC and HTTP share those, so a process with light +# traffic keeps a single connection to the sandbox host. # # pyqwest transports are thread-safe, so unlike the httpx transports they # replaced, the caches are process-global rather than per-thread. _transports: Dict[_TransportKey, ConnectionRetryTransport] = {} # The httpx adapter over each pool, shared by every httpx client on it. _httpx_transports: Dict[_TransportKey, PyqwestTransport] = {} +_envd_transports: Dict[_EnvdPoolKey, EnvdPoolTransport] = {} +_envd_httpx_transports: Dict[_EnvdPoolKey, PyqwestTransport] = {} def get_pyqwest_transport( @@ -132,16 +213,15 @@ def get_httpx_transport( proxy: Optional[ProxyConfig], read_timeout: Optional[float] = None, http2: bool = True, - pool_shard: int = 0, ) -> PyqwestTransport: """The httpx adapter over the shared pool of :func:`get_pyqwest_transport`, for the generated httpx clients (control - plane, envd HTTP API, volume content). The adapter holds no state of its - own and does not close the pool, so closing an httpx client leaves the - pool intact for the other clients on it.""" - key = (proxy, read_timeout, http2, pool_shard) + plane, volume content). The adapter holds no state of its own and does not + close the pool, so closing an httpx client leaves the pool intact for the + other clients on it.""" + key = (proxy, read_timeout, http2, 0) # Resolve the pool before taking the lock: it takes the same one. - pool = get_pyqwest_transport(proxy, read_timeout, http2, pool_shard) + pool = get_pyqwest_transport(proxy, read_timeout, http2) with _transport_lock: transport = _httpx_transports.get(key) if transport is None: @@ -150,18 +230,55 @@ def get_httpx_transport( return transport +def get_envd_pyqwest_transport( + proxy: Optional[ProxyConfig], + read_timeout: Optional[float] = None, + http2: bool = True, +) -> EnvdPoolTransport: + """The shared envd transport for the given tuning: the envd RPC clients + take it directly, the envd HTTP API through :func:`get_envd_httpx_transport`, + so both draw on the same pools and load counts. Its pools are the shards + of :func:`get_pyqwest_transport` with the same tuning, sized by + :func:`e2b.api.envd_pool_balancer`.""" + key = (proxy, read_timeout, http2) + with _transport_lock: + transport = _envd_transports.get(key) + if transport is None: + transport = EnvdPoolTransport( + lambda shard: get_pyqwest_transport(proxy, read_timeout, http2, shard), + envd_pool_balancer(http2), + ) + _envd_transports[key] = transport + return transport + + +def get_envd_httpx_transport( + proxy: Optional[ProxyConfig], + read_timeout: Optional[float] = None, + http2: bool = True, +) -> PyqwestTransport: + """The httpx adapter over :func:`get_envd_pyqwest_transport`, for the + generated envd HTTP API clients.""" + key = (proxy, read_timeout, http2) + pool = get_envd_pyqwest_transport(proxy, read_timeout, http2) + with _transport_lock: + transport = _envd_httpx_transports.get(key) + if transport is None: + transport = PyqwestTransport(pool) + _envd_httpx_transports[key] = transport + return transport + + def get_transport( config: ConnectionConfig, http2: bool = True, *, for_streaming: bool = False, - pool_shard: int = 0, ) -> 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 shared httpx transport factory for the control-plane REST API; + :func:`get_envd_transport` is its counterpart for the envd HTTP API. For + TLS connections ALPN negotiates the HTTP version (HTTP/2 against the E2B + API), like the http2-enabled httpx transport this replaced. ``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: @@ -182,29 +299,27 @@ def get_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 + config: ConnectionConfig, + http2: Optional[bool] = None, + *, + for_streaming: bool = False, ) -> PyqwestTransport: - """The envd HTTP API's transport, sharded by sandbox ID. + """The envd HTTP API's transport (file transfers, health checks), on the + load-balanced envd pools rather than the control plane's single pool. - Envd RPC and non-streaming HTTP traffic for one sandbox resolve the same - shard, retaining their shared connection while spreading different - sandboxes over a bounded number of connections to the stable sandbox host. - Streaming HTTP traffic uses the same shard number with a separate - 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. + ``http2`` defaults to the config's ``sandbox_http2`` option; ``False`` + pins the envd traffic to HTTP/1.1 (see :func:`get_transport` for what + that changes). ``for_streaming`` selects the read-timeout-keyed pools, + as for :func:`get_transport`. """ - return get_transport( - config, - http2, - for_streaming=for_streaming, - pool_shard=envd_pool_shard(config), + return get_envd_httpx_transport( + proxy_to_config(config.proxy), + READ_TIMEOUT if for_streaming else None, + config.sandbox_http2 if http2 is None else http2, ) diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 7745080b2e..701764de38 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -88,6 +88,14 @@ class ApiParams(TypedDict, total=False): sandbox_url: Optional[str] """URL to connect to sandbox, defaults to `E2B_SANDBOX_URL` environment variable.""" + sandbox_http2: Optional[bool] + """Whether requests to the sandbox (commands, filesystem, PTY) may use + HTTP/2, defaults to `E2B_SANDBOX_HTTP2` environment variable or `True`. + Set to `False` to pin them to HTTP/1.1, which uses one connection per + concurrent request instead of multiplexing streams over shared connections + — for example when an intermediary on the path retires or mishandles + long-lived HTTP/2 connections. Does not affect requests to the E2B API.""" + class ApiParamsWithLogger(ApiParams, total=False): """:class:`ApiParams` plus the construction-time ``logger``. @@ -196,6 +204,10 @@ def _api_url(): def _sandbox_url(): return os.getenv("E2B_SANDBOX_URL") + @staticmethod + def _sandbox_http2(): + return (os.getenv("E2B_SANDBOX_HTTP2") or "true").lower() != "false" + @staticmethod def _get_request_source() -> Optional[str]: source = os.getenv("E2B_USER_AGENT_SOURCE") @@ -243,6 +255,7 @@ def __init__( validate_api_key: Optional[bool] = None, api_url: Optional[str] = None, sandbox_url: Optional[str] = None, + sandbox_http2: Optional[bool] = None, request_timeout: Optional[float] = None, headers: Optional[Dict[str, str]] = None, api_headers: Optional[Dict[str, str]] = None, @@ -284,6 +297,11 @@ def __init__( self._sandbox_url: Optional[str] = ( sandbox_url or ConnectionConfig._sandbox_url() ) + self.sandbox_http2 = ( + sandbox_http2 + if sandbox_http2 is not None + else ConnectionConfig._sandbox_http2() + ) @staticmethod def _get_request_timeout( @@ -362,6 +380,7 @@ def get_api_params( debug = opts.get("debug") proxy = opts.get("proxy") sandbox_url = opts.get("sandbox_url") + sandbox_http2 = opts.get("sandbox_http2") retries = opts.get("retries") req_headers = self.headers.copy() @@ -401,6 +420,9 @@ def get_api_params( if sandbox_url is not None else cast(Optional[str], self._sandbox_url) ), + sandbox_http2=( + sandbox_http2 if sandbox_http2 is not None else self.sandbox_http2 + ), logger=self.logger, retries=retries if retries is not None else self.retries, ) diff --git a/packages/python-sdk/e2b/envd/client_async/__init__.py b/packages/python-sdk/e2b/envd/client_async/__init__.py index f3827cc76f..2d310755f5 100644 --- a/packages/python-sdk/e2b/envd/client_async/__init__.py +++ b/packages/python-sdk/e2b/envd/client_async/__init__.py @@ -15,8 +15,8 @@ from connectrpc.errors import ConnectError from pyqwest import Client, Request, Response, Transport -from e2b.api import envd_pool_shard, proxy_to_config -from e2b.api.client_async import get_pyqwest_transport +from e2b.api import proxy_to_config +from e2b.api.client_async import get_envd_pyqwest_transport from e2b.connection_config import ConnectionConfig from e2b.envd.client_shared import ( ENVD_JSON_CODEC, @@ -66,24 +66,24 @@ def create_rpc_client( config: ConnectionConfig, ) -> TClient: """Build a generated async connectrpc client (e.g. ``ProcessClient``) - wired with the shared pyqwest transport (which retries failed connects, - see :class:`e2b.api.client_async.ConnectionRetryTransport`), the envd - JSON codec, and the SDK's default-header and logging interceptors. + wired with the shared envd transport (load-balanced pools that retry + failed connects, see :class:`e2b.api.client_async.EnvdPoolTransport`), the + envd JSON codec, and the SDK's default-header and logging interceptors. Compression is disabled (see ``ENVD_RPC_COMPRESSION``). The plain-error normalization is the one RPC-only transport concern, so it - wraps the shared pool per client instead of being cached with it — a - stateless wrapper over the pool the envd HTTP API uses for the same - sandbox, which is what lets both share a single HTTP/2 connection. - connectrpc arms the per-call deadline around the transport, so retry - backoff counts against the request timeout, and the normalization sits - outside the retries so it converts the settled response once. + wraps the shared transport per client instead of being cached with it — a + stateless wrapper over the very pools the envd HTTP API uses, which is + what lets both share connections. connectrpc arms the per-call deadline + around the transport, so retry backoff counts against the request timeout, + and the normalization sits outside the retries so it converts the settled + response once. """ http_client = Client( PlainHTTPErrorTransport( - get_pyqwest_transport( + get_envd_pyqwest_transport( proxy_to_config(config.proxy), - pool_shard=envd_pool_shard(config), + http2=config.sandbox_http2, ) ) ) diff --git a/packages/python-sdk/e2b/envd/client_sync/__init__.py b/packages/python-sdk/e2b/envd/client_sync/__init__.py index dfbdde4eee..c1e822a2f7 100644 --- a/packages/python-sdk/e2b/envd/client_sync/__init__.py +++ b/packages/python-sdk/e2b/envd/client_sync/__init__.py @@ -9,8 +9,8 @@ SyncTransport, ) -from e2b.api import envd_pool_shard, proxy_to_config -from e2b.api.client_sync import get_pyqwest_transport +from e2b.api import proxy_to_config +from e2b.api.client_sync import get_envd_pyqwest_transport from e2b.connection_config import ConnectionConfig from e2b.envd.client_shared import ( ENVD_JSON_CODEC, @@ -59,26 +59,26 @@ def create_rpc_client( config: ConnectionConfig, ) -> TClient: """Build a generated sync connectrpc client (e.g. ``ProcessClientSync``) - wired with the shared pyqwest transport (which retries failed connects, - see :class:`e2b.api.client_sync.ConnectionRetryTransport`), the envd JSON - codec, and the SDK's default-header and logging interceptors. Compression - is disabled (see ``ENVD_RPC_COMPRESSION``). The client is stateless per - call and its connection pool is process-global, so one instance serves all - threads. + wired with the shared envd transport (load-balanced pools that retry + failed connects, see :class:`e2b.api.client_sync.EnvdPoolTransport`), the + envd JSON codec, and the SDK's default-header and logging interceptors. + Compression is disabled (see ``ENVD_RPC_COMPRESSION``). The client is + stateless per call and its connection pools are process-global, so one + instance serves all threads. The plain-error normalization is the one RPC-only transport concern, so it - wraps the shared pool per client instead of being cached with it — a - stateless wrapper over the pool the envd HTTP API uses for the same - sandbox, which is what lets both share a single HTTP/2 connection. - connectrpc arms the per-call deadline around the transport, so retry - backoff counts against the request timeout, and the normalization sits - outside the retries so it converts the settled response once. + wraps the shared transport per client instead of being cached with it — a + stateless wrapper over the very pools the envd HTTP API uses, which is + what lets both share connections. connectrpc arms the per-call deadline + around the transport, so retry backoff counts against the request timeout, + and the normalization sits outside the retries so it converts the settled + response once. """ http_client = SyncClient( PlainHTTPErrorTransport( - get_pyqwest_transport( + get_envd_pyqwest_transport( proxy_to_config(config.proxy), - pool_shard=envd_pool_shard(config), + http2=config.sandbox_http2, ) ) ) diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index f7ac9d5231..197fa6b878 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -18,13 +18,16 @@ import e2b.api.client_sync as api_client_sync from e2b.retry import AsyncRetryableTransport, RetryableTransport from e2b.api import ( - envd_pool_shard, + EnvdPoolBalancer, pool_idle_timeout, pool_max_idle_per_host, proxy_to_config, ) from e2b.api.client_async import get_api_client as get_async_api_client from e2b.api.client_async import get_envd_api as get_async_envd_api +from e2b.api.client_async import ( + get_envd_pyqwest_transport as get_async_envd_pyqwest_transport, +) from e2b.api.client_async import get_envd_transport as get_async_envd_transport from e2b.api.client_async import ( get_pyqwest_transport as get_async_pyqwest_transport, @@ -32,6 +35,9 @@ from e2b.api.client_async import get_transport as get_async_transport from e2b.api.client_sync import get_api_client as get_sync_api_client from e2b.api.client_sync import get_envd_api as get_sync_envd_api +from e2b.api.client_sync import ( + get_envd_pyqwest_transport as get_sync_envd_pyqwest_transport, +) from e2b.api.client_sync import get_envd_transport as get_sync_envd_transport 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 @@ -53,18 +59,66 @@ def sandbox_config(test_api_key: str, sandbox_id: str) -> ConnectionConfig: ) -@pytest.mark.parametrize("pool_shards", [1, 4, 8]) -def test_envd_pool_shard_respects_configured_count( - test_api_key, monkeypatch, pool_shards -): - monkeypatch.setattr(api, "envd_pool_shards", pool_shards) +def test_envd_pool_balancer_opens_pools_only_as_they_fill(): + balancer = EnvdPoolBalancer(max_pools=3, streams_per_pool=2) + + # The first pool takes requests up to its stream bound before a second + # one is opened; ties resolve to the lowest index. + assert [balancer.acquire() for _ in range(2)] == [0, 0] + assert balancer.acquire() == 1 + assert balancer.active_streams == (2, 1) + # Then whichever pool is least loaded, so a burst spreads evenly. + assert balancer.acquire() == 1 + assert [balancer.acquire() for _ in range(2)] == [2, 2] + assert balancer.active_streams == (2, 2, 2) + + # At the pool cap the least-loaded pool is used regardless of its load. + assert balancer.acquire() == 0 + assert balancer.acquire() == 1 + assert balancer.active_streams == (3, 3, 2) + + # Released slots are reused before any pool grows further. + balancer.release(0) + balancer.release(0) + assert balancer.acquire() == 0 + assert balancer.active_streams == (2, 3, 2) + + +def test_envd_pool_balancer_with_one_pool_never_grows(): + balancer = EnvdPoolBalancer(max_pools=1, streams_per_pool=1) + + assert [balancer.acquire() for _ in range(5)] == [0] * 5 + assert balancer.active_streams == (5,) + + +def test_envd_pool_balancer_is_thread_safe(): + balancer = EnvdPoolBalancer(max_pools=4, streams_per_pool=100) - assigned = { - envd_pool_shard(sandbox_config(test_api_key, f"sbx-{index}")) - for index in range(100) - } + def churn(): + for _ in range(1000): + balancer.release(balancer.acquire()) - assert assigned == set(range(pool_shards)) + with ThreadPoolExecutor(max_workers=8) as executor: + for future in [executor.submit(churn) for _ in range(8)]: + future.result() + + assert sum(balancer.active_streams) == 0 + + +@pytest.mark.parametrize("http2", [True, False]) +def test_envd_transports_size_their_balancer_from_the_env(monkeypatch, http2): + monkeypatch.setattr(api, "envd_pool_shards", 7) + monkeypatch.setattr(api, "envd_pool_streams", 42) + reset_transport_caches() + + try: + for module in (api_client_sync, api_client_async): + balancer = module.get_envd_pyqwest_transport(None, http2=http2).balancer + assert balancer._streams_per_pool == 42 + # HTTP/1.1 has no per-connection stream limit to spread over. + assert balancer._max_pools == (7 if http2 else 1) + finally: + reset_transport_caches() def test_sync_api_client_proxy_uses_explicit_transport(test_api_key): @@ -159,10 +213,10 @@ def test_sync_transports_keyed_by_http_version(test_api_key): 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 for each HTTP version. - assert envd_negotiated is negotiated - assert envd_http1 is http1 + # Envd traffic is load-balanced over its own pools rather than sharing + # the control plane's single pool. + assert envd_negotiated is not negotiated + assert envd_http1 is not 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 ( @@ -180,29 +234,47 @@ def test_sync_transports_keyed_by_http_version(test_api_key): reset_transport_caches() -def test_sync_envd_transports_are_consistently_sharded_by_sandbox( - test_api_key, monkeypatch -): - monkeypatch.setattr(api, "envd_pool_shards", 4) +def test_sync_envd_transports_are_shared_across_sandboxes(test_api_key): reset_transport_caches() first = sandbox_config(test_api_key, "sbx-0") - same_shard = sandbox_config(test_api_key, "sbx-2") - different_shard = sandbox_config(test_api_key, "sbx-1") + second = sandbox_config(test_api_key, "sbx-1") 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 - ) - # Generic API traffic remains on shard zero rather than multiplying - # control-plane connections for every envd shard. + # One balancer sees every sandbox's traffic, so it can spread the load + # across pools by what is actually in flight. + assert get_sync_envd_transport(first) is get_sync_envd_transport(second) + # Generic API traffic keeps its own pool rather than competing with + # sandbox streams for connections. assert get_sync_envd_transport(first) is not get_sync_transport(first) finally: reset_transport_caches() +def test_sync_envd_transport_follows_sandbox_http2_option(test_api_key): + reset_transport_caches() + default = sandbox_config(test_api_key, "sbx-0") + http1 = ConnectionConfig(api_key=test_api_key, sandbox_http2=False) + + try: + assert default.sandbox_http2 is True + assert get_sync_envd_transport(default) is get_sync_envd_transport( + default, http2=True + ) + assert get_sync_envd_transport(http1) is get_sync_envd_transport( + default, http2=False + ) + assert get_sync_envd_transport(http1) is not get_sync_envd_transport(default) + # The explicit argument wins over the option. + assert get_sync_envd_transport(http1, http2=True) is get_sync_envd_transport( + default + ) + assert get_sync_envd_api(http1, "https://sandbox.e2b.app")._transport is ( + get_sync_envd_transport(http1) + ) + finally: + reset_transport_caches() + + def test_sync_transports_pass_http_version_to_pyqwest(test_api_key, monkeypatch): # `http_version=None` leaves the version to ALPN (HTTP/2 against the E2B # API), `HTTP1` pins HTTP/1.1. Which version was negotiated is only @@ -223,11 +295,20 @@ def record(**kwargs): get_sync_transport(config) get_sync_transport(config, http2=False) # A third pool: same version as the call above, different idle bound. - # (`get_envd_transport(config, http2=False)` would be a cache hit and - # build nothing, since it shares the control plane's pool.) - get_sync_envd_transport(config, http2=False, for_streaming=True) - - assert captured == [None, HTTPVersion.HTTP1, HTTPVersion.HTTP1] + get_sync_transport(config, http2=False, for_streaming=True) + # Envd pools are opened on first use with their transport's version; + # shard zero is the control plane's pool above, already built. + get_sync_envd_transport(config, http2=False) + api_client_sync.get_envd_pyqwest_transport(None, http2=False).open_pool(1) + api_client_sync.get_envd_pyqwest_transport(None).open_pool(1) + + assert captured == [ + None, + HTTPVersion.HTTP1, + HTTPVersion.HTTP1, + HTTPVersion.HTTP1, + None, + ] finally: reset_transport_caches() @@ -318,8 +399,11 @@ 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 client._transport is get_sync_envd_transport(config) + assert streaming._transport is get_sync_envd_transport( + config, for_streaming=True + ) + assert streaming._transport is not client._transport for header, value in config.sandbox_headers.items(): assert client.headers[header] == value finally: @@ -403,10 +487,10 @@ async def test_async_transports_keyed_by_http_version(test_api_key): 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 for each HTTP version. - assert envd_negotiated is negotiated - assert envd_http1 is http1 + # Envd traffic is load-balanced over its own pools rather than sharing + # the control plane's single pool. + assert envd_negotiated is not negotiated + assert envd_http1 is not 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 @@ -419,25 +503,39 @@ async def test_async_transports_keyed_by_http_version(test_api_key): @pytest.mark.asyncio -async def test_async_envd_transports_are_consistently_sharded_by_sandbox( - test_api_key, monkeypatch -): - monkeypatch.setattr(api, "envd_pool_shards", 4) +async def test_async_envd_transports_are_shared_across_sandboxes(test_api_key): reset_transport_caches() first = sandbox_config(test_api_key, "sbx-0") - same_shard = sandbox_config(test_api_key, "sbx-2") - different_shard = sandbox_config(test_api_key, "sbx-1") + second = 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) is get_async_envd_transport(second) assert get_async_envd_transport(first) is not get_async_transport(first) finally: reset_transport_caches() +@pytest.mark.asyncio +async def test_async_envd_transport_follows_sandbox_http2_option(test_api_key): + reset_transport_caches() + default = sandbox_config(test_api_key, "sbx-0") + http1 = ConnectionConfig(api_key=test_api_key, sandbox_http2=False) + + try: + assert get_async_envd_transport(http1) is get_async_envd_transport( + default, http2=False + ) + assert get_async_envd_transport(http1) is not get_async_envd_transport(default) + assert get_async_envd_transport(http1, http2=True) is get_async_envd_transport( + default + ) + assert get_async_envd_api(http1, "https://sandbox.e2b.app")._transport is ( + get_async_envd_transport(http1) + ) + finally: + reset_transport_caches() + + @pytest.mark.asyncio async def test_async_transports_pass_http_version_to_pyqwest(test_api_key, monkeypatch): reset_transport_caches() @@ -455,9 +553,20 @@ def record(**kwargs): get_async_transport(config) get_async_transport(config, http2=False) # A third pool: same version as the call above, different idle bound. - get_async_envd_transport(config, http2=False, for_streaming=True) - - assert captured == [None, HTTPVersion.HTTP1, HTTPVersion.HTTP1] + get_async_transport(config, http2=False, for_streaming=True) + # Envd pools are opened on first use with their transport's version; + # shard zero is the control plane's pool above, already built. + get_async_envd_transport(config, http2=False) + api_client_async.get_envd_pyqwest_transport(None, http2=False).open_pool(1) + api_client_async.get_envd_pyqwest_transport(None).open_pool(1) + + assert captured == [ + None, + HTTPVersion.HTTP1, + HTTPVersion.HTTP1, + HTTPVersion.HTTP1, + None, + ] finally: reset_transport_caches() @@ -538,14 +647,22 @@ async def test_async_envd_api_client_wiring(test_api_key): config = ConnectionConfig(api_key=test_api_key) client = get_async_envd_api(config, "https://sandbox.e2b.app") + streaming = get_async_envd_api( + config, "https://sandbox.e2b.app", for_streaming=True + ) try: assert client.base_url == "https://sandbox.e2b.app" - assert client._transport is get_async_transport(config) + assert client._transport is get_async_envd_transport(config) + assert streaming._transport is get_async_envd_transport( + config, for_streaming=True + ) + assert streaming._transport is not client._transport for header, value in config.sandbox_headers.items(): assert client.headers[header] == value finally: await client.aclose() + await streaming.aclose() reset_transport_caches() @@ -585,7 +702,11 @@ def do_GET(self): self.wfile.flush() time.sleep(5) return - self.wfile.write(body) + try: + self.wfile.write(body) + except BrokenPipeError: + # A client that closed a streamed response before reading the body. + pass def do_POST(self): length = int(self.headers.get("Content-Length", 0)) @@ -1041,7 +1162,10 @@ 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, PyqwestTransport) - assert api_httpx._transport.transport is envd_api._transport + # The envd transport's first pool is the control plane's. + envd_transport = get_sync_envd_pyqwest_transport(proxy_to_config(config.proxy)) + assert envd_api._transport._transport is envd_transport + assert envd_transport.open_pool(0) is pool assert api_httpx.request("GET", "/sandboxes").status_code == 200 api_httpx.close() @@ -1070,7 +1194,9 @@ async def test_async_closing_one_client_leaves_the_shared_pool_open( try: assert isinstance(api_httpx._transport, AsyncRetryableTransport) assert isinstance(envd_api._transport, AsyncPyqwestTransport) - assert api_httpx._transport.transport is envd_api._transport + envd_transport = get_async_envd_pyqwest_transport(proxy_to_config(config.proxy)) + assert envd_api._transport._transport is envd_transport + assert envd_transport.open_pool(0) is pool assert (await api_httpx.request("GET", "/sandboxes")).status_code == 200 await api_httpx.aclose() @@ -1084,3 +1210,94 @@ async def test_async_closing_one_client_leaves_the_shared_pool_open( finally: await envd_api.aclose() reset_transport_caches() + + +@pytest.mark.parametrize("http2", [True, False]) +def test_sync_envd_transport_counts_requests_until_their_body_is_done( + test_api_key, echo_server, http2 +): + # A request holds its slot on the pool it was sent on for as long as its + # body may still be streaming — through the httpx adapter as much as for + # a direct RPC — and gives it up exactly once however it ends: fully read, + # closed early, timed out, or failed to connect. + reset_transport_caches() + config = ConnectionConfig(api_key=test_api_key, sandbox_http2=http2) + envd_api = get_sync_envd_api(config, echo_server) + transport = get_sync_envd_pyqwest_transport(None, http2=http2) + balancer = transport.balancer + + try: + response = envd_api.get("/health") + assert response.status_code == 200 + assert response.json()["path"] == "/health" + assert balancer.active_streams == (0,) + + with envd_api.stream("GET", "/health") as streamed: + assert streamed.status_code == 200 + assert balancer.active_streams == (1,) + assert balancer.active_streams == (0,) + + rpc_response = transport.execute_sync( + SyncRequest("GET", f"{echo_server}/health") + ) + assert rpc_response.status == 200 + assert balancer.active_streams == (1,) + assert b"".join(rpc_response.content).startswith(b"{") + assert balancer.active_streams == (0,) + rpc_response.close() + assert balancer.active_streams == (0,) + + with pytest.raises(httpx.ReadTimeout): + envd_api.get("/stall", timeout=0.2) + assert balancer.active_streams == (0,) + + with pytest.raises(httpx.ConnectError): + envd_api.get("http://127.0.0.1:9/health") + assert balancer.active_streams == (0,) + finally: + envd_api.close() + reset_transport_caches() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("http2", [True, False]) +async def test_async_envd_transport_counts_requests_until_their_body_is_done( + test_api_key, echo_server, http2 +): + reset_transport_caches() + config = ConnectionConfig(api_key=test_api_key, sandbox_http2=http2) + envd_api = get_async_envd_api(config, echo_server) + transport = get_async_envd_pyqwest_transport(None, http2=http2) + balancer = transport.balancer + + try: + response = await envd_api.get("/health") + assert response.status_code == 200 + assert response.json()["path"] == "/health" + assert balancer.active_streams == (0,) + + async with envd_api.stream("GET", "/health") as streamed: + assert streamed.status_code == 200 + assert balancer.active_streams == (1,) + assert balancer.active_streams == (0,) + + rpc_response = await transport.execute(Request("GET", f"{echo_server}/health")) + assert rpc_response.status == 200 + assert balancer.active_streams == (1,) + assert b"".join([chunk async for chunk in rpc_response.content]).startswith( + b"{" + ) + assert balancer.active_streams == (0,) + await rpc_response.aclose() + assert balancer.active_streams == (0,) + + with pytest.raises(httpx.ReadTimeout): + await envd_api.get("/stall", timeout=0.2) + assert balancer.active_streams == (0,) + + with pytest.raises(httpx.ConnectError): + await envd_api.get("http://127.0.0.1:9/health") + assert balancer.active_streams == (0,) + finally: + await envd_api.aclose() + reset_transport_caches() diff --git a/packages/python-sdk/tests/test_connection_config.py b/packages/python-sdk/tests/test_connection_config.py index 4241143ac1..619771d1a6 100644 --- a/packages/python-sdk/tests/test_connection_config.py +++ b/packages/python-sdk/tests/test_connection_config.py @@ -249,3 +249,30 @@ def test_retries_default_to_three_and_propagate(): def test_retries_reject_invalid_values(retries): with pytest.raises(InvalidArgumentException): ConnectionConfig(retries=retries) + + +def test_sandbox_http2_defaults_on_and_propagates(monkeypatch): + monkeypatch.delenv("E2B_SANDBOX_HTTP2", raising=False) + + assert ConnectionConfig().sandbox_http2 is True + assert ConnectionConfig().get_api_params()["sandbox_http2"] is True + + config = ConnectionConfig(sandbox_http2=False) + assert config.sandbox_http2 is False + # Reconstructed configs (a sandbox's sub-clients) keep the setting, and a + # per-call value overrides it. + assert config.get_api_params()["sandbox_http2"] is False + assert ConnectionConfig(**config.get_api_params()).sandbox_http2 is False + assert config.get_api_params(sandbox_http2=True)["sandbox_http2"] is True + + +@pytest.mark.parametrize( + ("value", "expected"), + [("false", False), ("FALSE", False), ("true", True), ("", True)], +) +def test_sandbox_http2_reads_env_var(monkeypatch, value, expected): + monkeypatch.setenv("E2B_SANDBOX_HTTP2", value) + + assert ConnectionConfig().sandbox_http2 is expected + # The explicit argument wins over the environment. + assert ConnectionConfig(sandbox_http2=not expected).sandbox_http2 is not expected diff --git a/packages/python-sdk/tests/test_env_var_parsing.py b/packages/python-sdk/tests/test_env_var_parsing.py index fc83438d0f..9283dca328 100644 --- a/packages/python-sdk/tests/test_env_var_parsing.py +++ b/packages/python-sdk/tests/test_env_var_parsing.py @@ -11,6 +11,7 @@ "E2B_MAX_KEEPALIVE_CONNECTIONS", "E2B_CONNECTION_RETRIES", "E2B_ENVD_POOL_SHARDS", + "E2B_ENVD_POOL_STREAMS", ) @@ -26,7 +27,8 @@ def test_empty_env_vars_fall_back_to_defaults(monkeypatch): assert api.pool_idle_timeout == 300 assert api.pool_max_idle_per_host == 20 assert api.connection_retries == 3 - assert api.envd_pool_shards == 4 + assert api.envd_pool_shards == 16 + assert api.envd_pool_streams == 90 finally: monkeypatch.undo() _reload() @@ -36,11 +38,25 @@ def test_set_env_vars_are_honored(monkeypatch): monkeypatch.setenv("E2B_KEEPALIVE_EXPIRY", "42") monkeypatch.setenv("E2B_CONNECTION_RETRIES", "5") monkeypatch.setenv("E2B_ENVD_POOL_SHARDS", "8") + monkeypatch.setenv("E2B_ENVD_POOL_STREAMS", "50") try: api = _reload() assert api.pool_idle_timeout == 42 assert api.connection_retries == 5 assert api.envd_pool_shards == 8 + assert api.envd_pool_streams == 50 + finally: + monkeypatch.undo() + _reload() + + +def test_pool_sizes_are_clamped_to_at_least_one(monkeypatch): + monkeypatch.setenv("E2B_ENVD_POOL_SHARDS", "0") + monkeypatch.setenv("E2B_ENVD_POOL_STREAMS", "-5") + try: + api = _reload() + assert api.envd_pool_shards == 1 + assert api.envd_pool_streams == 1 finally: monkeypatch.undo() _reload() diff --git a/packages/python-sdk/tests/test_envd_client_transport.py b/packages/python-sdk/tests/test_envd_client_transport.py index 1b8c6203e5..66d0212b50 100644 --- a/packages/python-sdk/tests/test_envd_client_transport.py +++ b/packages/python-sdk/tests/test_envd_client_transport.py @@ -140,17 +140,22 @@ def test_async_pool_is_cached_per_proxy(): assert api_client_sync.get_pyqwest_transport(None) is not pool_a -def test_rpc_clients_run_on_the_shared_pool(test_api_key, monkeypatch): +@pytest.mark.parametrize("http2", [True, False]) +def test_rpc_clients_run_on_the_shared_envd_transport(test_api_key, monkeypatch, http2): # The RPC stack is the plain-HTTP-error normalization wrapping the very - # pool the httpx clients use, so an envd RPC and an envd HTTP call to the - # same sandbox share one HTTP/2 connection. `pyqwest.SyncClient` doesn't - # hand its transport back, so record what the normalization is given. - config = ConnectionConfig(api_key=test_api_key) - pool = api_client_sync.get_pyqwest_transport(None) - async_pool = api_client_async.get_pyqwest_transport(None) - # The httpx adapters every REST client uses sit on those same pools. - assert api_client_sync.get_httpx_transport(None)._transport is pool - assert api_client_async.get_httpx_transport(None)._transport is async_pool + # envd transport the envd httpx clients use, so envd RPC and envd HTTP + # calls draw on the same pools and load counts. `pyqwest.SyncClient` + # doesn't hand its transport back, so record what the normalization is + # given. + config = ConnectionConfig(api_key=test_api_key, sandbox_http2=http2) + pool = api_client_sync.get_envd_pyqwest_transport(None, http2=http2) + async_pool = api_client_async.get_envd_pyqwest_transport(None, http2=http2) + assert isinstance(pool, api_client_sync.EnvdPoolTransport) + assert isinstance(async_pool, api_client_async.EnvdPoolTransport) + assert pool is not api_client_sync.get_envd_pyqwest_transport(None, http2=not http2) + # The httpx adapters the envd HTTP API uses sit on those same transports. + assert api_client_sync.get_envd_transport(config)._transport is pool + assert api_client_async.get_envd_transport(config)._transport is async_pool wrapped = [] for module in (client_sync, client_async): diff --git a/packages/python-sdk/tests/test_envd_stream_capacity.py b/packages/python-sdk/tests/test_envd_stream_capacity.py index 1f3dbefb3b..5c4023bd18 100644 --- a/packages/python-sdk/tests/test_envd_stream_capacity.py +++ b/packages/python-sdk/tests/test_envd_stream_capacity.py @@ -1,8 +1,10 @@ """Long-lived envd streams must not all contend for one HTTP/2 connection.""" import asyncio +import time from collections import Counter from concurrent.futures import ThreadPoolExecutor +from typing import Callable import pytest from envd_frame_server import stream_capacity_server @@ -36,9 +38,32 @@ def sandbox_config(sandbox_id: str) -> ConnectionConfig: ) -def test_sync_envd_spreads_repeated_waves_across_reused_connections(monkeypatch): - """Fresh sync sandboxes keep filling the same four pools evenly.""" - monkeypatch.setattr(api, "envd_pool_shards", 4) +# The customer scenario this guards: 192 long-running agent commands, whose +# streams must all be admitted although the sandbox host caps each HTTP/2 +# connection at 100 concurrent streams — and must be, whether they come from +# 192 sandboxes or a single busy one. +STREAMS = 192 +POOL_STREAMS = 90 + + +def connection_loads(server) -> Counter: + return Counter(connection_id for connection_id, _ in server.active_streams) + + +def wait_for(condition: Callable[[], bool], timeout: float = 2.0) -> None: + deadline = time.monotonic() + timeout + while not condition(): + assert time.monotonic() < deadline, "condition not met in time" + time.sleep(0.01) + + +def sandbox_ids(spread: bool): + return [f"sbx-{index if spread else 0}" for index in range(STREAMS)] + + +@pytest.mark.parametrize("spread", [True, False], ids=["many-sandboxes", "one-sandbox"]) +def test_sync_envd_grows_pools_with_in_flight_streams(monkeypatch, spread): + monkeypatch.setattr(api, "envd_pool_streams", POOL_STREAMS) reset_transport_caches() build_transport = api_client_sync.SyncHTTPTransport @@ -50,41 +75,45 @@ def build_http2_transport(**kwargs): monkeypatch.setattr(api_client_sync, "SyncHTTPTransport", build_http2_transport) + def open_streams(ids): + opened = [] + for sandbox_id in ids: + client = create_sync_rpc_client( + ProcessClientSync, + f"http://127.0.0.1:{server.port}", + sandbox_config(sandbox_id), + ) + opened.append(as_sync_stream(client.connect(ConnectRequest()))) + with ThreadPoolExecutor(max_workers=len(opened)) as executor: + futures = [executor.submit(next, stream) for stream in opened] + for future in futures: + future.result(timeout=2) + return opened + streams = [] try: with stream_capacity_server(max_concurrent_streams=100) as server: - connection_ids = None - for wave in range(3): - wave_streams = [] - for index in range(80): - client = create_sync_rpc_client( - ProcessClientSync, - f"http://127.0.0.1:{server.port}", - sandbox_config(f"sbx-wave-{wave}-{index}"), - ) - wave_streams.append( - as_sync_stream(client.connect(ConnectRequest())) - ) - streams.extend(wave_streams) - - stream_count = len(server.streams) - with ThreadPoolExecutor(max_workers=len(wave_streams)) as executor: - futures = [executor.submit(next, stream) for stream in wave_streams] - events = [future.result(timeout=2) for future in futures] - - assert len(events) == 80 - wave_counts = Counter( - connection_id for connection_id, _ in server.streams[stream_count:] - ) - assert len(wave_counts) == 4 - if connection_ids is None: - connection_ids = set(wave_counts) - assert set(wave_counts) == connection_ids - assert max(wave_counts.values()) - min(wave_counts.values()) <= 2 - assert len(server.active_streams) == (wave + 1) * 80 - - assert len(server.connections) == 4 - assert len(server.streams) == 240 + balancer = api_client_sync.get_envd_pyqwest_transport(None).balancer + + streams += open_streams(sandbox_ids(spread)) + + # Every stream is open, over just enough connections: pools fill to + # their bound before the next one is opened. + assert len(server.active_streams) == STREAMS + assert balancer.active_streams == (90, 90, 12) + assert sorted(connection_loads(server).values()) == [12, 90, 90] + + # Ending streams frees their slots — the first pool's entirely — + # which the next burst reuses instead of opening connections. + for stream in streams[:100]: + stream.close() + wait_for(lambda: len(server.active_streams) == STREAMS - 100) + assert balancer.active_streams == (0, 80, 12) + + streams += open_streams(sandbox_ids(spread)[:50]) + + assert balancer.active_streams == (31, 80, 31) + assert len(server.connections) == 3 server.assert_no_errors() finally: for stream in streams: @@ -93,60 +122,54 @@ def build_http2_transport(**kwargs): @pytest.mark.asyncio -async def test_async_envd_spreads_repeated_waves_across_reused_connections( - monkeypatch, -): - """Fresh async sandboxes keep filling the same four pools evenly.""" - monkeypatch.setattr(api, "envd_pool_shards", 4) +@pytest.mark.parametrize("spread", [True, False], ids=["many-sandboxes", "one-sandbox"]) +async def test_async_envd_grows_pools_with_in_flight_streams(monkeypatch, spread): + monkeypatch.setattr(api, "envd_pool_streams", POOL_STREAMS) reset_transport_caches() build_transport = api_client_async.HTTPTransport def build_http2_transport(**kwargs): - # Production negotiates HTTP/2 over TLS. The frame server is plaintext, - # so force prior knowledge while retaining the production factory/cache. kwargs["http_version"] = HTTPVersion.HTTP2 return build_transport(**kwargs) monkeypatch.setattr(api_client_async, "HTTPTransport", build_http2_transport) + async def open_streams(ids): + opened = [] + for sandbox_id in ids: + client = create_async_rpc_client( + ProcessClient, + f"http://127.0.0.1:{server.port}", + sandbox_config(sandbox_id), + ) + opened.append(as_async_stream(client.connect(ConnectRequest()))) + events = await asyncio.gather( + *(first_event(stream, 2) for stream in opened), return_exceptions=True + ) + assert not [event for event in events if isinstance(event, BaseException)] + return opened + streams = [] try: with stream_capacity_server(max_concurrent_streams=100) as server: - connection_ids = None - for wave in range(3): - wave_streams = [] - for index in range(80): - client = create_async_rpc_client( - ProcessClient, - f"http://127.0.0.1:{server.port}", - sandbox_config(f"sbx-wave-{wave}-{index}"), - ) - wave_streams.append( - as_async_stream(client.connect(ConnectRequest())) - ) - streams.extend(wave_streams) - - stream_count = len(server.streams) - events = await asyncio.gather( - *(first_event(stream, 0.5) for stream in wave_streams), - return_exceptions=True, - ) - - assert not [ - event for event in events if isinstance(event, BaseException) - ] - wave_counts = Counter( - connection_id for connection_id, _ in server.streams[stream_count:] - ) - assert len(wave_counts) == 4 - if connection_ids is None: - connection_ids = set(wave_counts) - assert set(wave_counts) == connection_ids - assert max(wave_counts.values()) - min(wave_counts.values()) <= 2 - assert len(server.active_streams) == (wave + 1) * 80 - - assert len(server.connections) == 4 - assert len(server.streams) == 240 + balancer = api_client_async.get_envd_pyqwest_transport(None).balancer + + streams += await open_streams(sandbox_ids(spread)) + + assert len(server.active_streams) == STREAMS + assert balancer.active_streams == (90, 90, 12) + assert sorted(connection_loads(server).values()) == [12, 90, 90] + + await asyncio.gather(*(stream.aclose() for stream in streams[:100])) + await asyncio.to_thread( + wait_for, lambda: len(server.active_streams) == STREAMS - 100 + ) + assert balancer.active_streams == (0, 80, 12) + + streams += await open_streams(sandbox_ids(spread)[:50]) + + assert balancer.active_streams == (31, 80, 31) + assert len(server.connections) == 3 server.assert_no_errors() finally: await asyncio.gather( diff --git a/packages/python-sdk/tests/transport_caches.py b/packages/python-sdk/tests/transport_caches.py index 0ff5ce5f55..5eccb978de 100644 --- a/packages/python-sdk/tests/transport_caches.py +++ b/packages/python-sdk/tests/transport_caches.py @@ -19,3 +19,5 @@ def reset_transport_caches() -> None: for module in (api_client_sync, api_client_async): module._transports.clear() module._httpx_transports.clear() + module._envd_transports.clear() + module._envd_httpx_transports.clear() From c939e9953b3a6f8886bbb2d281fa92e042ae2408 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:06:05 +0000 Subject: [PATCH 2/4] Use a released ephemeral port for the refused-connect case (Windows times out on port 9) Co-Authored-By: mish@e2b.dev --- .../tests/test_api_client_transport.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index 197fa6b878..93e726a26e 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -2,6 +2,7 @@ import base64 import json import logging +import socket import threading import time from concurrent.futures import ThreadPoolExecutor @@ -738,6 +739,17 @@ class _EchoServer(ThreadingHTTPServer): request_queue_size = 64 +@pytest.fixture +def refused_url(): + """A loopback URL nothing listens on. Unlike a well-known closed port + (say 9), a just-released ephemeral port is refused at once on every OS — + Windows lets connects to filtered ports time out instead.""" + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + return f"http://127.0.0.1:{port}/health" + + @pytest.fixture def echo_server(): _EchoHandler.rate_limit_requests = 0 @@ -1214,7 +1226,7 @@ async def test_async_closing_one_client_leaves_the_shared_pool_open( @pytest.mark.parametrize("http2", [True, False]) def test_sync_envd_transport_counts_requests_until_their_body_is_done( - test_api_key, echo_server, http2 + test_api_key, echo_server, refused_url, http2 ): # A request holds its slot on the pool it was sent on for as long as its # body may still be streaming — through the httpx adapter as much as for @@ -1252,7 +1264,7 @@ def test_sync_envd_transport_counts_requests_until_their_body_is_done( assert balancer.active_streams == (0,) with pytest.raises(httpx.ConnectError): - envd_api.get("http://127.0.0.1:9/health") + envd_api.get(refused_url) assert balancer.active_streams == (0,) finally: envd_api.close() @@ -1262,7 +1274,7 @@ def test_sync_envd_transport_counts_requests_until_their_body_is_done( @pytest.mark.asyncio @pytest.mark.parametrize("http2", [True, False]) async def test_async_envd_transport_counts_requests_until_their_body_is_done( - test_api_key, echo_server, http2 + test_api_key, echo_server, refused_url, http2 ): reset_transport_caches() config = ConnectionConfig(api_key=test_api_key, sandbox_http2=http2) @@ -1296,7 +1308,7 @@ async def test_async_envd_transport_counts_requests_until_their_body_is_done( assert balancer.active_streams == (0,) with pytest.raises(httpx.ConnectError): - await envd_api.get("http://127.0.0.1:9/health") + await envd_api.get(refused_url) assert balancer.active_streams == (0,) finally: await envd_api.aclose() From 821efc7b005c760512e6bc2a364ac372345eae3d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:38:19 +0000 Subject: [PATCH 3/4] Replace sandbox_http2 with global http_version option; make failed-connect test deterministic Co-Authored-By: mish@e2b.dev --- .changeset/envd-pool-growth-http1.md | 2 +- packages/js-sdk/README.md | 8 +-- packages/js-sdk/src/api/http2.ts | 12 ++-- packages/js-sdk/src/api/index.ts | 2 +- packages/js-sdk/src/connectionConfig.ts | 34 ++++++---- packages/js-sdk/src/index.ts | 1 + packages/js-sdk/src/sandbox/index.ts | 11 +--- packages/js-sdk/tests/api/http2.test.ts | 29 ++++++++- .../js-sdk/tests/connectionConfig.test.ts | 47 ++++++++------ ...ttpVersion.test.ts => httpVersion.test.ts} | 15 +++-- packages/python-sdk/README.md | 6 +- packages/python-sdk/e2b/__init__.py | 2 + .../e2b/api/client_async/__init__.py | 13 ++-- .../e2b/api/client_sync/__init__.py | 13 ++-- packages/python-sdk/e2b/connection_config.py | 39 +++++++----- .../e2b/envd/client_async/__init__.py | 2 +- .../e2b/envd/client_sync/__init__.py | 2 +- .../tests/test_api_client_transport.py | 63 ++++++++++++------- .../tests/test_connection_config.py | 38 ++++++----- .../tests/test_envd_client_transport.py | 4 +- 20 files changed, 216 insertions(+), 127 deletions(-) rename packages/js-sdk/tests/sandbox/{envdHttpVersion.test.ts => httpVersion.test.ts} (76%) diff --git a/.changeset/envd-pool-growth-http1.md b/.changeset/envd-pool-growth-http1.md index c9e80459a7..27458ccfe9 100644 --- a/.changeset/envd-pool-growth-http1.md +++ b/.changeset/envd-pool-growth-http1.md @@ -3,6 +3,6 @@ '@e2b/python-sdk': minor --- -Add a `sandbox_http2` (Python) / `sandboxHttp2` (JS) connection option, also settable with the `E2B_SANDBOX_HTTP2` environment variable, to pin sandbox traffic (commands, filesystem, PTY) to HTTP/1.1. Requests to the E2B API are unaffected. +Add an `http_version` (Python) / `httpVersion` (JS) connection option, `"http1"` or `"http2"` (default), also settable with the `E2B_HTTP_VERSION` environment variable, to pin requests to the E2B API and to sandboxes (commands, filesystem, PTY) to HTTP/1.1. The Python SDK now spreads sandbox traffic across HTTP/2 connection pools by load instead of hashing each sandbox to one of four fixed pools: every request goes to the pool with the fewest requests in flight, and a further pool is opened only once every open pool carries `E2B_ENVD_POOL_STREAMS` (default 90) requests — below the 100 concurrent streams the sandbox host allows per connection — up to `E2B_ENVD_POOL_SHARDS` (default raised from 4 to 16) pools. A process running a single sandbox keeps one connection; one with hundreds of long-running commands is no longer queued behind a saturated connection, whether they belong to many sandboxes or to a few. diff --git a/packages/js-sdk/README.md b/packages/js-sdk/README.md index 5911e2fbbe..15e0278bd7 100644 --- a/packages/js-sdk/README.md +++ b/packages/js-sdk/README.md @@ -66,15 +66,15 @@ const paginator = Sandbox.list() Per-call options still take precedence over the client's options, and clients are isolated from each other and from the env-configured top-level exports. -### Sandbox transport +### HTTP version -In Node, sandbox `commands`, `files` and `pty` traffic goes through a bounded pool of HTTP/2 connections (`E2B_ENVD_RPC_CONNECTIONS`, default `200`, for streams). To avoid HTTP/2 for sandbox traffic altogether — for example when an intermediary on the path retires long-lived HTTP/2 connections — pin it to HTTP/1.1. Requests to the E2B API are unaffected: +In Node, requests to the E2B API and to sandboxes (`commands`, `files`, `pty`) go through bounded pools of HTTP/2 connections (`E2B_API_CONNECTIONS`, default `100`; `E2B_ENVD_RPC_CONNECTIONS`, default `200`). To avoid HTTP/2 altogether — for example when an intermediary on the path retires long-lived HTTP/2 connections — pin the SDK to HTTP/1.1: ```ts -const sandbox = await Sandbox.create({ sandboxHttp2: false }) +const sandbox = await Sandbox.create({ httpVersion: 'http1' }) ``` -or, for the whole process, `E2B_SANDBOX_HTTP2=false`. +or, for the whole process, `E2B_HTTP_VERSION=http1`. ### 5. Code execution with Code Interpreter diff --git a/packages/js-sdk/src/api/http2.ts b/packages/js-sdk/src/api/http2.ts index 2a46c3fcb4..5f2e345057 100644 --- a/packages/js-sdk/src/api/http2.ts +++ b/packages/js-sdk/src/api/http2.ts @@ -11,19 +11,19 @@ const DEFAULT_API_CONNECTION_LIMIT = 100 // Override via env if your workload needs different. const DEFAULT_API_INFLIGHT_LIMIT = 1000 -// Fetchers are cached per proxy so requests without a proxy keep sharing a -// single dispatcher while each distinct proxy URL gets its own. +// Fetchers are cached per proxy and HTTP version so requests without a proxy +// keep sharing a single dispatcher while each distinct proxy URL gets its own. const apiFetchers = new Map() -export function createApiFetch(proxy?: string): typeof fetch { - const key = proxy ?? '' +export function createApiFetch(proxy?: string, http2 = true): typeof fetch { + const key = `${http2 ? 'h2' : 'h1'}:${proxy ?? ''}` const cached = apiFetchers.get(key) if (cached) { return cached } - const apiFetch = createApiFetchForRuntime(runtime, { proxy }) + const apiFetch = createApiFetchForRuntime(runtime, { proxy, http2 }) apiFetchers.set(key, apiFetch) return apiFetch @@ -35,6 +35,7 @@ export function createApiFetchForRuntime( connectionLimit?: number inflightLimit?: number proxy?: string + http2?: boolean loadUndici?: () => Promise } = {} ): typeof fetch { @@ -45,6 +46,7 @@ export function createApiFetchForRuntime( connections: options.connectionLimit ?? getApiConnectionLimit(), inflightLimit: options.inflightLimit ?? getApiInflightLimit(), proxy: options.proxy, + http2: options.http2, loadUndici: options.loadUndici, }) ) diff --git a/packages/js-sdk/src/api/index.ts b/packages/js-sdk/src/api/index.ts index de6289f587..3899fe94a2 100644 --- a/packages/js-sdk/src/api/index.ts +++ b/packages/js-sdk/src/api/index.ts @@ -107,7 +107,7 @@ class ApiClient { this.api = createClient({ baseUrl: config.apiUrl, fetch: withRateLimitRetry( - createApiFetch(config.proxy), + createApiFetch(config.proxy, config.httpVersion === 'http2'), config.retries, config.requestTimeoutMs ), diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index 712227efd3..16a05bdf43 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -13,6 +13,11 @@ export const KEEPALIVE_PING_INTERVAL_SEC = 50 // 50 seconds export const KEEPALIVE_PING_HEADER = 'Keepalive-Ping-Interval' +/** + * HTTP version the SDK speaks to the E2B API and to sandboxes. + */ +export type HttpVersion = 'http1' | 'http2' + /** * Connection options for requests to the API. */ @@ -89,16 +94,15 @@ export interface ConnectionOpts { */ proxy?: string /** - * Whether requests to the sandbox (commands, filesystem, PTY) may use - * HTTP/2. Set to `false` to pin them to HTTP/1.1, which uses one connection - * per concurrent request instead of multiplexing streams over shared - * connections — for example when an intermediary on the path retires or - * mishandles long-lived HTTP/2 connections. Does not affect requests to the - * E2B API. Only applies in Node. + * HTTP version for requests to the E2B API and to sandboxes (commands, + * filesystem, PTY). `'http2'` multiplexes streams over shared connections; + * `'http1'` pins them to HTTP/1.1 with one connection per concurrent + * request — for example when an intermediary on the path retires or + * mishandles long-lived HTTP/2 connections. Only applies in Node. * - * @default E2B_SANDBOX_HTTP2 // environment variable or `true` + * @default E2B_HTTP_VERSION // environment variable or `'http2'` */ - sandboxHttp2?: boolean + httpVersion?: HttpVersion /** * Additional headers to send with E2B API requests. @@ -451,7 +455,7 @@ export class ConnectionConfig { readonly requestSource?: string readonly proxy?: string - readonly sandboxHttp2: boolean + readonly httpVersion: HttpVersion constructor(opts?: ConnectionOpts) { this.apiKey = opts?.apiKey || ConnectionConfig.apiKey @@ -465,7 +469,7 @@ export class ConnectionConfig { this.headers = { ...(opts?.headers ?? {}), ...(opts?.apiHeaders ?? {}) } ConnectionConfig.applyUserAgent(this.headers, this.requestSource) this.proxy = opts?.proxy - this.sandboxHttp2 = opts?.sandboxHttp2 ?? ConnectionConfig.sandboxHttp2 + this.httpVersion = opts?.httpVersion ?? ConnectionConfig.httpVersion this.apiUrl = opts?.apiUrl || @@ -524,8 +528,14 @@ export class ConnectionConfig { return getEnvVar('E2B_SANDBOX_URL') } - private static get sandboxHttp2() { - return (getEnvVar('E2B_SANDBOX_HTTP2') || 'true').toLowerCase() !== 'false' + private static get httpVersion(): HttpVersion { + const value = (getEnvVar('E2B_HTTP_VERSION') || 'http2').toLowerCase() + if (value !== 'http1' && value !== 'http2') { + throw new Error( + `E2B_HTTP_VERSION must be 'http1' or 'http2', got '${value}'` + ) + } + return value } private static get debug() { diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 1a49508e63..afe6917fcf 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -5,6 +5,7 @@ export { ConnectionConfig } from './connectionConfig' export type { ConnectionConfigOpts, ConnectionOpts, + HttpVersion, Username, } from './connectionConfig' export { diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index 3685131422..577a18515b 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -161,14 +161,9 @@ export class Sandbox extends SandboxApi { 'E2b-Sandbox-Id': this.sandboxId, 'E2b-Sandbox-Port': this.envdPort.toString(), } - const envdFetch = createEnvdFetch( - this.connectionConfig.proxy, - this.connectionConfig.sandboxHttp2 - ) - const envdRpcFetch = createEnvdRpcFetch( - this.connectionConfig.proxy, - this.connectionConfig.sandboxHttp2 - ) + const http2 = this.connectionConfig.httpVersion === 'http2' + const envdFetch = createEnvdFetch(this.connectionConfig.proxy, http2) + const envdRpcFetch = createEnvdRpcFetch(this.connectionConfig.proxy, http2) const rpcTransport = createConnectTransport({ baseUrl: this.envdApiUrl, diff --git a/packages/js-sdk/tests/api/http2.test.ts b/packages/js-sdk/tests/api/http2.test.ts index 3238e16b72..1ca9d582ae 100644 --- a/packages/js-sdk/tests/api/http2.test.ts +++ b/packages/js-sdk/tests/api/http2.test.ts @@ -144,17 +144,44 @@ test('late-binds the global fetch fallback when undici cannot be loaded', async } }) -test('caches API fetchers per proxy', async () => { +test('pins the API dispatcher to HTTP/1.1 when http2 is false', async () => { + const agents: Array<{ allowH2?: boolean; connections?: number }> = [] + + class Agent { + constructor(options: { allowH2?: boolean; connections?: number }) { + agents.push(options) + } + } + + const undiciFetch = vi.fn(() => Promise.resolve(new Response('ok'))) + + const { createApiFetchForRuntime } = await import('../../src/api/http2') + + const fetcher = createApiFetchForRuntime('node', { + connectionLimit: 3, + http2: false, + loadUndici: () => Promise.resolve({ Agent, fetch: undiciFetch }), + }) + await fetcher('https://api.e2b.app/sandboxes') + + expect(agents).toEqual([{ allowH2: false, connections: 3 }]) +}) + +test('caches API fetchers per proxy and HTTP version', async () => { const { createApiFetch } = 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 noProxyH1 = createApiFetch(undefined, false) expect(createApiFetch()).toBe(noProxy) + expect(createApiFetch(undefined, true)).toBe(noProxy) expect(createApiFetch('http://127.0.0.1:8080')).toBe(proxyA) + expect(createApiFetch(undefined, false)).toBe(noProxyH1) expect(proxyA).not.toBe(noProxy) expect(proxyA).not.toBe(proxyB) + expect(noProxyH1).not.toBe(noProxy) }) test('getApiConnectionLimit throws on a malformed env value', async () => { diff --git a/packages/js-sdk/tests/connectionConfig.test.ts b/packages/js-sdk/tests/connectionConfig.test.ts index a635c5f05d..1e907693d1 100644 --- a/packages/js-sdk/tests/connectionConfig.test.ts +++ b/packages/js-sdk/tests/connectionConfig.test.ts @@ -15,7 +15,7 @@ beforeEach(() => { E2B_API_URL: process.env.E2B_API_URL, E2B_DOMAIN: process.env.E2B_DOMAIN, E2B_SANDBOX_URL: process.env.E2B_SANDBOX_URL, - E2B_SANDBOX_HTTP2: process.env.E2B_SANDBOX_HTTP2, + E2B_HTTP_VERSION: process.env.E2B_HTTP_VERSION, E2B_DEBUG: process.env.E2B_DEBUG, E2B_USER_AGENT_SOURCE: process.env.E2B_USER_AGENT_SOURCE, } @@ -165,29 +165,40 @@ test('sandbox_url stays localhost in debug mode', () => { ) }) -test('sandboxHttp2 defaults to true and reads E2B_SANDBOX_HTTP2', () => { - delete process.env.E2B_SANDBOX_HTTP2 - assert.equal(new ConnectionConfig().sandboxHttp2, true) +test('httpVersion defaults to http2 and reads E2B_HTTP_VERSION', () => { + delete process.env.E2B_HTTP_VERSION + assert.equal(new ConnectionConfig().httpVersion, 'http2') assert.equal( - new ConnectionConfig({ sandboxHttp2: false }).sandboxHttp2, - false + new ConnectionConfig({ httpVersion: 'http1' }).httpVersion, + 'http1' ) - process.env.E2B_SANDBOX_HTTP2 = 'false' - assert.equal(new ConnectionConfig().sandboxHttp2, false) - process.env.E2B_SANDBOX_HTTP2 = 'FALSE' - assert.equal(new ConnectionConfig().sandboxHttp2, false) - process.env.E2B_SANDBOX_HTTP2 = 'true' - assert.equal(new ConnectionConfig().sandboxHttp2, true) + process.env.E2B_HTTP_VERSION = 'http1' + assert.equal(new ConnectionConfig().httpVersion, 'http1') + process.env.E2B_HTTP_VERSION = 'HTTP1' + assert.equal(new ConnectionConfig().httpVersion, 'http1') + process.env.E2B_HTTP_VERSION = 'http2' + assert.equal(new ConnectionConfig().httpVersion, 'http2') + + process.env.E2B_HTTP_VERSION = 'http3' + assert.throws(() => new ConnectionConfig(), /E2B_HTTP_VERSION/) + // An explicit option never consults the environment. + assert.equal( + new ConnectionConfig({ httpVersion: 'http1' }).httpVersion, + 'http1' + ) }) -test('sandboxHttp2 in args has priority over env var', () => { - process.env.E2B_SANDBOX_HTTP2 = 'false' - assert.equal(new ConnectionConfig({ sandboxHttp2: true }).sandboxHttp2, true) +test('httpVersion in args has priority over env var', () => { + process.env.E2B_HTTP_VERSION = 'http1' + assert.equal( + new ConnectionConfig({ httpVersion: 'http2' }).httpVersion, + 'http2' + ) - // Per-call options and bound options keep the flag when merged. - const merged = ConnectionConfig.mergeOpts({ sandboxHttp2: false }, {}) - assert.equal(new ConnectionConfig(merged).sandboxHttp2, false) + // Per-call options and bound options keep the option when merged. + const merged = ConnectionConfig.mergeOpts({ httpVersion: 'http1' }, {}) + assert.equal(new ConnectionConfig(merged).httpVersion, 'http1') }) test('debug false in args overrides E2B_DEBUG env var', () => { diff --git a/packages/js-sdk/tests/sandbox/envdHttpVersion.test.ts b/packages/js-sdk/tests/sandbox/httpVersion.test.ts similarity index 76% rename from packages/js-sdk/tests/sandbox/envdHttpVersion.test.ts rename to packages/js-sdk/tests/sandbox/httpVersion.test.ts index 2d486e6a75..3e7a4187eb 100644 --- a/packages/js-sdk/tests/sandbox/envdHttpVersion.test.ts +++ b/packages/js-sdk/tests/sandbox/httpVersion.test.ts @@ -16,10 +16,13 @@ vi.mock('../../src/envd/http2', () => ({ afterEach(() => { vi.clearAllMocks() - delete process.env.E2B_SANDBOX_HTTP2 + delete process.env.E2B_HTTP_VERSION }) -async function createSandbox(opts: { sandboxHttp2?: boolean; proxy?: string }) { +async function createSandbox(opts: { + httpVersion?: 'http1' | 'http2' + proxy?: string +}) { const { ConnectionConfig, Sandbox } = await import('../../src') const config = new ConnectionConfig(opts) return new Sandbox({ @@ -38,8 +41,8 @@ test('envd fetchers default to HTTP/2', async () => { assert.deepEqual(mocks.createEnvdRpcFetch.mock.calls[0], [undefined, true]) }) -test('sandboxHttp2: false pins envd HTTP and RPC fetchers to HTTP/1.1', async () => { - await createSandbox({ sandboxHttp2: false, proxy: 'http://127.0.0.1:8080' }) +test('httpVersion: http1 pins envd HTTP and RPC fetchers to HTTP/1.1', async () => { + await createSandbox({ httpVersion: 'http1', proxy: 'http://127.0.0.1:8080' }) assert.deepEqual(mocks.createEnvdFetch.mock.calls[0], [ 'http://127.0.0.1:8080', @@ -51,8 +54,8 @@ test('sandboxHttp2: false pins envd HTTP and RPC fetchers to HTTP/1.1', async () ]) }) -test('E2B_SANDBOX_HTTP2=false pins envd fetchers to HTTP/1.1', async () => { - process.env.E2B_SANDBOX_HTTP2 = 'false' +test('E2B_HTTP_VERSION=http1 pins envd fetchers to HTTP/1.1', async () => { + process.env.E2B_HTTP_VERSION = 'http1' await createSandbox({}) assert.deepEqual(mocks.createEnvdFetch.mock.calls[0], [undefined, false]) diff --git a/packages/python-sdk/README.md b/packages/python-sdk/README.md index c18c26dfd9..7291532bd4 100644 --- a/packages/python-sdk/README.md +++ b/packages/python-sdk/README.md @@ -76,15 +76,15 @@ If one process needs more than `16 × 90` concurrent sandbox streams, raise the E2B_ENVD_POOL_SHARDS=32 python eval.py ``` -To avoid HTTP/2 for sandbox traffic altogether — for example when an intermediary on the path retires long-lived HTTP/2 connections — pin it to HTTP/1.1, which uses one connection per concurrent request. Requests to the E2B API are unaffected: +To avoid HTTP/2 altogether — for example when an intermediary on the path retires long-lived HTTP/2 connections — pin the SDK to HTTP/1.1, which uses one connection per concurrent request: ```py from e2b import Sandbox -sandbox = Sandbox.create(sandbox_http2=False) +sandbox = Sandbox.create(http_version="http1") ``` -or, for the whole process, `E2B_SANDBOX_HTTP2=false`. +or, for the whole process, `E2B_HTTP_VERSION=http1`. ### 5. Code execution with Code Interpreter diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index ff010de53d..f8371b895f 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -32,6 +32,7 @@ from .connection_config import ( ApiParams, ConnectionConfig, + HttpVersion, ProxyTypes, Username, ) @@ -171,6 +172,7 @@ # Connection config "ConnectionConfig", "VolumeConnectionConfig", + "HttpVersion", "ProxyTypes", "ApiParams", "VolumeApiParams", diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index f21530b5fe..9e42b8d8cc 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -266,7 +266,7 @@ def get_envd_httpx_transport( def get_transport( config: ConnectionConfig, - http2: bool = True, + http2: Optional[bool] = None, *, for_streaming: bool = False, ) -> AsyncPyqwestTransport: @@ -275,8 +275,9 @@ def get_transport( TLS connections ALPN negotiates the HTTP version (HTTP/2 against the E2B API), like the http2-enabled httpx transport this replaced. - ``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: + ``http2`` defaults to the config's ``http_version`` option; ``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 only resets its stream and the server may never notice, while HTTP/1.1's one-connection-per-request closes the connection and the server observes @@ -293,7 +294,7 @@ def get_transport( return get_httpx_transport( proxy_to_config(config.proxy), READ_TIMEOUT if for_streaming else None, - http2, + config.http_version == "http2" if http2 is None else http2, ) @@ -306,7 +307,7 @@ def get_envd_transport( """The envd HTTP API's transport (file transfers, health checks), on the load-balanced envd pools rather than the control plane's single pool. - ``http2`` defaults to the config's ``sandbox_http2`` option; ``False`` + ``http2`` defaults to the config's ``http_version`` option; ``False`` pins the envd traffic to HTTP/1.1 (see :func:`get_transport` for what that changes). ``for_streaming`` selects the read-timeout-keyed pools, as for :func:`get_transport`. @@ -314,7 +315,7 @@ def get_envd_transport( return get_envd_httpx_transport( proxy_to_config(config.proxy), READ_TIMEOUT if for_streaming else None, - config.sandbox_http2 if http2 is None else http2, + config.http_version == "http2" if http2 is None else http2, ) diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index 662ada3ae7..88494a1c27 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -271,7 +271,7 @@ def get_envd_httpx_transport( def get_transport( config: ConnectionConfig, - http2: bool = True, + http2: Optional[bool] = None, *, for_streaming: bool = False, ) -> PyqwestTransport: @@ -280,8 +280,9 @@ def get_transport( TLS connections ALPN negotiates the HTTP version (HTTP/2 against the E2B API), like the http2-enabled httpx transport this replaced. - ``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: + ``http2`` defaults to the config's ``http_version`` option; ``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 only resets its stream and the server may never notice, while HTTP/1.1's one-connection-per-request closes the connection and the server observes @@ -298,7 +299,7 @@ def get_transport( return get_httpx_transport( proxy_to_config(config.proxy), READ_TIMEOUT if for_streaming else None, - http2, + config.http_version == "http2" if http2 is None else http2, ) @@ -311,7 +312,7 @@ def get_envd_transport( """The envd HTTP API's transport (file transfers, health checks), on the load-balanced envd pools rather than the control plane's single pool. - ``http2`` defaults to the config's ``sandbox_http2`` option; ``False`` + ``http2`` defaults to the config's ``http_version`` option; ``False`` pins the envd traffic to HTTP/1.1 (see :func:`get_transport` for what that changes). ``for_streaming`` selects the read-timeout-keyed pools, as for :func:`get_transport`. @@ -319,7 +320,7 @@ def get_envd_transport( return get_envd_httpx_transport( proxy_to_config(config.proxy), READ_TIMEOUT if for_streaming else None, - config.sandbox_http2 if http2 is None else http2, + config.http_version == "http2" if http2 is None else http2, ) diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 701764de38..291b2b30f1 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -2,7 +2,7 @@ import os import re -from typing import cast, Mapping, Optional, Dict, TypedDict, Union +from typing import cast, Literal, Mapping, Optional, Dict, TypedDict, Union import httpx from typing_extensions import Unpack @@ -39,6 +39,8 @@ KEEPALIVE_PING_INTERVAL_SEC = 50 # 50 seconds KEEPALIVE_PING_HEADER = "Keepalive-Ping-Interval" +HttpVersion = Literal["http1", "http2"] + class ApiParams(TypedDict, total=False): """ @@ -88,13 +90,13 @@ class ApiParams(TypedDict, total=False): sandbox_url: Optional[str] """URL to connect to sandbox, defaults to `E2B_SANDBOX_URL` environment variable.""" - sandbox_http2: Optional[bool] - """Whether requests to the sandbox (commands, filesystem, PTY) may use - HTTP/2, defaults to `E2B_SANDBOX_HTTP2` environment variable or `True`. - Set to `False` to pin them to HTTP/1.1, which uses one connection per + http_version: Optional[HttpVersion] + """HTTP version for requests to the E2B API and to sandboxes (commands, + filesystem, PTY), defaults to `E2B_HTTP_VERSION` environment variable or + `"http2"`. `"http1"` pins them to HTTP/1.1, which uses one connection per concurrent request instead of multiplexing streams over shared connections — for example when an intermediary on the path retires or mishandles - long-lived HTTP/2 connections. Does not affect requests to the E2B API.""" + long-lived HTTP/2 connections.""" class ApiParamsWithLogger(ApiParams, total=False): @@ -205,8 +207,13 @@ def _sandbox_url(): return os.getenv("E2B_SANDBOX_URL") @staticmethod - def _sandbox_http2(): - return (os.getenv("E2B_SANDBOX_HTTP2") or "true").lower() != "false" + def _http_version() -> HttpVersion: + value = (os.getenv("E2B_HTTP_VERSION") or "http2").lower() + if value not in ("http1", "http2"): + raise ValueError( + f"E2B_HTTP_VERSION must be 'http1' or 'http2', got {value!r}" + ) + return cast(HttpVersion, value) @staticmethod def _get_request_source() -> Optional[str]: @@ -255,7 +262,7 @@ def __init__( validate_api_key: Optional[bool] = None, api_url: Optional[str] = None, sandbox_url: Optional[str] = None, - sandbox_http2: Optional[bool] = None, + http_version: Optional[HttpVersion] = None, request_timeout: Optional[float] = None, headers: Optional[Dict[str, str]] = None, api_headers: Optional[Dict[str, str]] = None, @@ -297,10 +304,10 @@ def __init__( self._sandbox_url: Optional[str] = ( sandbox_url or ConnectionConfig._sandbox_url() ) - self.sandbox_http2 = ( - sandbox_http2 - if sandbox_http2 is not None - else ConnectionConfig._sandbox_http2() + self.http_version: HttpVersion = ( + http_version + if http_version is not None + else ConnectionConfig._http_version() ) @staticmethod @@ -380,7 +387,7 @@ def get_api_params( debug = opts.get("debug") proxy = opts.get("proxy") sandbox_url = opts.get("sandbox_url") - sandbox_http2 = opts.get("sandbox_http2") + http_version = opts.get("http_version") retries = opts.get("retries") req_headers = self.headers.copy() @@ -420,8 +427,8 @@ def get_api_params( if sandbox_url is not None else cast(Optional[str], self._sandbox_url) ), - sandbox_http2=( - sandbox_http2 if sandbox_http2 is not None else self.sandbox_http2 + http_version=( + http_version if http_version is not None else self.http_version ), logger=self.logger, retries=retries if retries is not None else self.retries, diff --git a/packages/python-sdk/e2b/envd/client_async/__init__.py b/packages/python-sdk/e2b/envd/client_async/__init__.py index 2d310755f5..9a9b447452 100644 --- a/packages/python-sdk/e2b/envd/client_async/__init__.py +++ b/packages/python-sdk/e2b/envd/client_async/__init__.py @@ -83,7 +83,7 @@ def create_rpc_client( PlainHTTPErrorTransport( get_envd_pyqwest_transport( proxy_to_config(config.proxy), - http2=config.sandbox_http2, + http2=config.http_version == "http2", ) ) ) diff --git a/packages/python-sdk/e2b/envd/client_sync/__init__.py b/packages/python-sdk/e2b/envd/client_sync/__init__.py index c1e822a2f7..5a1a36d810 100644 --- a/packages/python-sdk/e2b/envd/client_sync/__init__.py +++ b/packages/python-sdk/e2b/envd/client_sync/__init__.py @@ -78,7 +78,7 @@ def create_rpc_client( PlainHTTPErrorTransport( get_envd_pyqwest_transport( proxy_to_config(config.proxy), - http2=config.sandbox_http2, + http2=config.http_version == "http2", ) ) ) diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index 93e726a26e..226be5fd49 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -2,7 +2,6 @@ import base64 import json import logging -import socket import threading import time from concurrent.futures import ThreadPoolExecutor @@ -251,13 +250,15 @@ def test_sync_envd_transports_are_shared_across_sandboxes(test_api_key): reset_transport_caches() -def test_sync_envd_transport_follows_sandbox_http2_option(test_api_key): +def test_sync_transports_follow_the_http_version_option(test_api_key): reset_transport_caches() default = sandbox_config(test_api_key, "sbx-0") - http1 = ConnectionConfig(api_key=test_api_key, sandbox_http2=False) + http1 = ConnectionConfig(api_key=test_api_key, http_version="http1") try: - assert default.sandbox_http2 is True + assert default.http_version == "http2" + assert get_sync_transport(http1) is get_sync_transport(default, http2=False) + assert get_sync_transport(http1) is not get_sync_transport(default) assert get_sync_envd_transport(default) is get_sync_envd_transport( default, http2=True ) @@ -517,12 +518,14 @@ async def test_async_envd_transports_are_shared_across_sandboxes(test_api_key): @pytest.mark.asyncio -async def test_async_envd_transport_follows_sandbox_http2_option(test_api_key): +async def test_async_transports_follow_the_http_version_option(test_api_key): reset_transport_caches() default = sandbox_config(test_api_key, "sbx-0") - http1 = ConnectionConfig(api_key=test_api_key, sandbox_http2=False) + http1 = ConnectionConfig(api_key=test_api_key, http_version="http1") try: + assert get_async_transport(http1) is get_async_transport(default, http2=False) + assert get_async_transport(http1) is not get_async_transport(default) assert get_async_envd_transport(http1) is get_async_envd_transport( default, http2=False ) @@ -739,15 +742,15 @@ class _EchoServer(ThreadingHTTPServer): request_queue_size = 64 -@pytest.fixture -def refused_url(): - """A loopback URL nothing listens on. Unlike a well-known closed port - (say 9), a just-released ephemeral port is refused at once on every OS — - Windows lets connects to filtered ports time out instead.""" - with socket.socket() as probe: - probe.bind(("127.0.0.1", 0)) - port = probe.getsockname()[1] - return f"http://127.0.0.1:{port}/health" +class RefusingPool: + """A pool whose every connect is refused, the way pyqwest reports it. Stands + in for a closed port, which not every OS refuses promptly.""" + + def execute_sync(self, request): + raise ConnectionError("connection refused") + + async def execute(self, request): + raise ConnectionError("connection refused") @pytest.fixture @@ -1226,14 +1229,16 @@ async def test_async_closing_one_client_leaves_the_shared_pool_open( @pytest.mark.parametrize("http2", [True, False]) def test_sync_envd_transport_counts_requests_until_their_body_is_done( - test_api_key, echo_server, refused_url, http2 + test_api_key, echo_server, http2 ): # A request holds its slot on the pool it was sent on for as long as its # body may still be streaming — through the httpx adapter as much as for # a direct RPC — and gives it up exactly once however it ends: fully read, # closed early, timed out, or failed to connect. reset_transport_caches() - config = ConnectionConfig(api_key=test_api_key, sandbox_http2=http2) + config = ConnectionConfig( + api_key=test_api_key, http_version="http2" if http2 else "http1" + ) envd_api = get_sync_envd_api(config, echo_server) transport = get_sync_envd_pyqwest_transport(None, http2=http2) balancer = transport.balancer @@ -1263,8 +1268,13 @@ def test_sync_envd_transport_counts_requests_until_their_body_is_done( envd_api.get("/stall", timeout=0.2) assert balancer.active_streams == (0,) - with pytest.raises(httpx.ConnectError): - envd_api.get(refused_url) + open_pool = transport.open_pool + transport.open_pool = lambda index: RefusingPool() + try: + with pytest.raises(httpx.ConnectError): + envd_api.get("/health") + finally: + transport.open_pool = open_pool assert balancer.active_streams == (0,) finally: envd_api.close() @@ -1274,10 +1284,12 @@ def test_sync_envd_transport_counts_requests_until_their_body_is_done( @pytest.mark.asyncio @pytest.mark.parametrize("http2", [True, False]) async def test_async_envd_transport_counts_requests_until_their_body_is_done( - test_api_key, echo_server, refused_url, http2 + test_api_key, echo_server, http2 ): reset_transport_caches() - config = ConnectionConfig(api_key=test_api_key, sandbox_http2=http2) + config = ConnectionConfig( + api_key=test_api_key, http_version="http2" if http2 else "http1" + ) envd_api = get_async_envd_api(config, echo_server) transport = get_async_envd_pyqwest_transport(None, http2=http2) balancer = transport.balancer @@ -1307,8 +1319,13 @@ async def test_async_envd_transport_counts_requests_until_their_body_is_done( await envd_api.get("/stall", timeout=0.2) assert balancer.active_streams == (0,) - with pytest.raises(httpx.ConnectError): - await envd_api.get(refused_url) + open_pool = transport.open_pool + transport.open_pool = lambda index: RefusingPool() + try: + with pytest.raises(httpx.ConnectError): + await envd_api.get("/health") + finally: + transport.open_pool = open_pool assert balancer.active_streams == (0,) finally: await envd_api.aclose() diff --git a/packages/python-sdk/tests/test_connection_config.py b/packages/python-sdk/tests/test_connection_config.py index 619771d1a6..5e4f1d3bb3 100644 --- a/packages/python-sdk/tests/test_connection_config.py +++ b/packages/python-sdk/tests/test_connection_config.py @@ -251,28 +251,38 @@ def test_retries_reject_invalid_values(retries): ConnectionConfig(retries=retries) -def test_sandbox_http2_defaults_on_and_propagates(monkeypatch): - monkeypatch.delenv("E2B_SANDBOX_HTTP2", raising=False) +def test_http_version_defaults_to_http2_and_propagates(monkeypatch): + monkeypatch.delenv("E2B_HTTP_VERSION", raising=False) - assert ConnectionConfig().sandbox_http2 is True - assert ConnectionConfig().get_api_params()["sandbox_http2"] is True + assert ConnectionConfig().http_version == "http2" + assert ConnectionConfig().get_api_params()["http_version"] == "http2" - config = ConnectionConfig(sandbox_http2=False) - assert config.sandbox_http2 is False + config = ConnectionConfig(http_version="http1") + assert config.http_version == "http1" # Reconstructed configs (a sandbox's sub-clients) keep the setting, and a # per-call value overrides it. - assert config.get_api_params()["sandbox_http2"] is False - assert ConnectionConfig(**config.get_api_params()).sandbox_http2 is False - assert config.get_api_params(sandbox_http2=True)["sandbox_http2"] is True + assert config.get_api_params()["http_version"] == "http1" + assert ConnectionConfig(**config.get_api_params()).http_version == "http1" + assert config.get_api_params(http_version="http2")["http_version"] == "http2" @pytest.mark.parametrize( ("value", "expected"), - [("false", False), ("FALSE", False), ("true", True), ("", True)], + [("http1", "http1"), ("HTTP1", "http1"), ("http2", "http2"), ("", "http2")], ) -def test_sandbox_http2_reads_env_var(monkeypatch, value, expected): - monkeypatch.setenv("E2B_SANDBOX_HTTP2", value) +def test_http_version_reads_env_var(monkeypatch, value, expected): + monkeypatch.setenv("E2B_HTTP_VERSION", value) - assert ConnectionConfig().sandbox_http2 is expected + assert ConnectionConfig().http_version == expected # The explicit argument wins over the environment. - assert ConnectionConfig(sandbox_http2=not expected).sandbox_http2 is not expected + other = "http2" if expected == "http1" else "http1" + assert ConnectionConfig(http_version=other).http_version == other + + +def test_http_version_rejects_unknown_env_value(monkeypatch): + monkeypatch.setenv("E2B_HTTP_VERSION", "http3") + + with pytest.raises(ValueError, match="E2B_HTTP_VERSION"): + ConnectionConfig() + # An explicit option never consults the environment. + assert ConnectionConfig(http_version="http1").http_version == "http1" diff --git a/packages/python-sdk/tests/test_envd_client_transport.py b/packages/python-sdk/tests/test_envd_client_transport.py index 66d0212b50..7e46593250 100644 --- a/packages/python-sdk/tests/test_envd_client_transport.py +++ b/packages/python-sdk/tests/test_envd_client_transport.py @@ -147,7 +147,9 @@ def test_rpc_clients_run_on_the_shared_envd_transport(test_api_key, monkeypatch, # calls draw on the same pools and load counts. `pyqwest.SyncClient` # doesn't hand its transport back, so record what the normalization is # given. - config = ConnectionConfig(api_key=test_api_key, sandbox_http2=http2) + config = ConnectionConfig( + api_key=test_api_key, http_version="http2" if http2 else "http1" + ) pool = api_client_sync.get_envd_pyqwest_transport(None, http2=http2) async_pool = api_client_async.get_envd_pyqwest_transport(None, http2=http2) assert isinstance(pool, api_client_sync.EnvdPoolTransport) From 9ae4d13c3b2c860daa848c970bba6b21b9a6f78d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:36:24 +0000 Subject: [PATCH 4/4] Keep HTTP version a transport-only argument; drop public http_version option and E2B_HTTP_VERSION Co-Authored-By: mish@e2b.dev --- ...ol-growth-http1.md => envd-pool-growth.md} | 3 - packages/js-sdk/README.md | 10 --- packages/js-sdk/src/api/index.ts | 2 +- packages/js-sdk/src/connectionConfig.ts | 27 -------- packages/js-sdk/src/index.ts | 1 - packages/js-sdk/src/sandbox/index.ts | 5 +- .../js-sdk/tests/connectionConfig.test.ts | 37 ----------- .../js-sdk/tests/sandbox/httpVersion.test.ts | 63 ------------------ packages/python-sdk/README.md | 10 --- packages/python-sdk/e2b/__init__.py | 2 - packages/python-sdk/e2b/api/__init__.py | 1 + .../e2b/api/client_async/__init__.py | 14 ++-- .../e2b/api/client_sync/__init__.py | 14 ++-- packages/python-sdk/e2b/connection_config.py | 31 +-------- .../e2b/envd/client_async/__init__.py | 5 +- .../e2b/envd/client_sync/__init__.py | 5 +- .../tests/test_api_client_transport.py | 66 +++---------------- .../tests/test_connection_config.py | 37 ----------- .../tests/test_envd_client_transport.py | 13 ++-- 19 files changed, 32 insertions(+), 314 deletions(-) rename .changeset/{envd-pool-growth-http1.md => envd-pool-growth.md} (72%) delete mode 100644 packages/js-sdk/tests/sandbox/httpVersion.test.ts diff --git a/.changeset/envd-pool-growth-http1.md b/.changeset/envd-pool-growth.md similarity index 72% rename from .changeset/envd-pool-growth-http1.md rename to .changeset/envd-pool-growth.md index 27458ccfe9..f7b35e0cf8 100644 --- a/.changeset/envd-pool-growth-http1.md +++ b/.changeset/envd-pool-growth.md @@ -1,8 +1,5 @@ --- -'e2b': minor '@e2b/python-sdk': minor --- -Add an `http_version` (Python) / `httpVersion` (JS) connection option, `"http1"` or `"http2"` (default), also settable with the `E2B_HTTP_VERSION` environment variable, to pin requests to the E2B API and to sandboxes (commands, filesystem, PTY) to HTTP/1.1. - The Python SDK now spreads sandbox traffic across HTTP/2 connection pools by load instead of hashing each sandbox to one of four fixed pools: every request goes to the pool with the fewest requests in flight, and a further pool is opened only once every open pool carries `E2B_ENVD_POOL_STREAMS` (default 90) requests — below the 100 concurrent streams the sandbox host allows per connection — up to `E2B_ENVD_POOL_SHARDS` (default raised from 4 to 16) pools. A process running a single sandbox keeps one connection; one with hundreds of long-running commands is no longer queued behind a saturated connection, whether they belong to many sandboxes or to a few. diff --git a/packages/js-sdk/README.md b/packages/js-sdk/README.md index 15e0278bd7..d4d2303f17 100644 --- a/packages/js-sdk/README.md +++ b/packages/js-sdk/README.md @@ -66,16 +66,6 @@ const paginator = Sandbox.list() Per-call options still take precedence over the client's options, and clients are isolated from each other and from the env-configured top-level exports. -### HTTP version - -In Node, requests to the E2B API and to sandboxes (`commands`, `files`, `pty`) go through bounded pools of HTTP/2 connections (`E2B_API_CONNECTIONS`, default `100`; `E2B_ENVD_RPC_CONNECTIONS`, default `200`). To avoid HTTP/2 altogether — for example when an intermediary on the path retires long-lived HTTP/2 connections — pin the SDK to HTTP/1.1: - -```ts -const sandbox = await Sandbox.create({ httpVersion: 'http1' }) -``` - -or, for the whole process, `E2B_HTTP_VERSION=http1`. - ### 5. Code execution with Code Interpreter If you need [`runCode()`](https://docs.e2b.dev/code-interpreting/analyze-data-with-ai?utm_source=npm&utm_medium=referral&utm_campaign=readme&utm_content=e2b), install the [Code Interpreter SDK](https://github.com/e2b-dev/code-interpreter): diff --git a/packages/js-sdk/src/api/index.ts b/packages/js-sdk/src/api/index.ts index 3899fe94a2..de6289f587 100644 --- a/packages/js-sdk/src/api/index.ts +++ b/packages/js-sdk/src/api/index.ts @@ -107,7 +107,7 @@ class ApiClient { this.api = createClient({ baseUrl: config.apiUrl, fetch: withRateLimitRetry( - createApiFetch(config.proxy, config.httpVersion === 'http2'), + createApiFetch(config.proxy), config.retries, config.requestTimeoutMs ), diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index 16a05bdf43..a9ef7fdf84 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -13,11 +13,6 @@ export const KEEPALIVE_PING_INTERVAL_SEC = 50 // 50 seconds export const KEEPALIVE_PING_HEADER = 'Keepalive-Ping-Interval' -/** - * HTTP version the SDK speaks to the E2B API and to sandboxes. - */ -export type HttpVersion = 'http1' | 'http2' - /** * Connection options for requests to the API. */ @@ -93,16 +88,6 @@ export interface ConnectionOpts { * @example 'http://user:pass@127.0.0.1:8080' */ proxy?: string - /** - * HTTP version for requests to the E2B API and to sandboxes (commands, - * filesystem, PTY). `'http2'` multiplexes streams over shared connections; - * `'http1'` pins them to HTTP/1.1 with one connection per concurrent - * request — for example when an intermediary on the path retires or - * mishandles long-lived HTTP/2 connections. Only applies in Node. - * - * @default E2B_HTTP_VERSION // environment variable or `'http2'` - */ - httpVersion?: HttpVersion /** * Additional headers to send with E2B API requests. @@ -455,7 +440,6 @@ export class ConnectionConfig { readonly requestSource?: string readonly proxy?: string - readonly httpVersion: HttpVersion constructor(opts?: ConnectionOpts) { this.apiKey = opts?.apiKey || ConnectionConfig.apiKey @@ -469,7 +453,6 @@ export class ConnectionConfig { this.headers = { ...(opts?.headers ?? {}), ...(opts?.apiHeaders ?? {}) } ConnectionConfig.applyUserAgent(this.headers, this.requestSource) this.proxy = opts?.proxy - this.httpVersion = opts?.httpVersion ?? ConnectionConfig.httpVersion this.apiUrl = opts?.apiUrl || @@ -528,16 +511,6 @@ export class ConnectionConfig { return getEnvVar('E2B_SANDBOX_URL') } - private static get httpVersion(): HttpVersion { - const value = (getEnvVar('E2B_HTTP_VERSION') || 'http2').toLowerCase() - if (value !== 'http1' && value !== 'http2') { - throw new Error( - `E2B_HTTP_VERSION must be 'http1' or 'http2', got '${value}'` - ) - } - return value - } - private static get debug() { return (getEnvVar('E2B_DEBUG') || 'false').toLowerCase() === 'true' } diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index afe6917fcf..1a49508e63 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -5,7 +5,6 @@ export { ConnectionConfig } from './connectionConfig' export type { ConnectionConfigOpts, ConnectionOpts, - HttpVersion, Username, } from './connectionConfig' export { diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index 577a18515b..f9964e2b16 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -161,9 +161,8 @@ export class Sandbox extends SandboxApi { 'E2b-Sandbox-Id': this.sandboxId, 'E2b-Sandbox-Port': this.envdPort.toString(), } - const http2 = this.connectionConfig.httpVersion === 'http2' - const envdFetch = createEnvdFetch(this.connectionConfig.proxy, http2) - const envdRpcFetch = createEnvdRpcFetch(this.connectionConfig.proxy, http2) + const envdFetch = createEnvdFetch(this.connectionConfig.proxy) + const envdRpcFetch = createEnvdRpcFetch(this.connectionConfig.proxy) const rpcTransport = createConnectTransport({ baseUrl: this.envdApiUrl, diff --git a/packages/js-sdk/tests/connectionConfig.test.ts b/packages/js-sdk/tests/connectionConfig.test.ts index 1e907693d1..8030249a6b 100644 --- a/packages/js-sdk/tests/connectionConfig.test.ts +++ b/packages/js-sdk/tests/connectionConfig.test.ts @@ -15,7 +15,6 @@ beforeEach(() => { E2B_API_URL: process.env.E2B_API_URL, E2B_DOMAIN: process.env.E2B_DOMAIN, E2B_SANDBOX_URL: process.env.E2B_SANDBOX_URL, - E2B_HTTP_VERSION: process.env.E2B_HTTP_VERSION, E2B_DEBUG: process.env.E2B_DEBUG, E2B_USER_AGENT_SOURCE: process.env.E2B_USER_AGENT_SOURCE, } @@ -165,42 +164,6 @@ test('sandbox_url stays localhost in debug mode', () => { ) }) -test('httpVersion defaults to http2 and reads E2B_HTTP_VERSION', () => { - delete process.env.E2B_HTTP_VERSION - assert.equal(new ConnectionConfig().httpVersion, 'http2') - assert.equal( - new ConnectionConfig({ httpVersion: 'http1' }).httpVersion, - 'http1' - ) - - process.env.E2B_HTTP_VERSION = 'http1' - assert.equal(new ConnectionConfig().httpVersion, 'http1') - process.env.E2B_HTTP_VERSION = 'HTTP1' - assert.equal(new ConnectionConfig().httpVersion, 'http1') - process.env.E2B_HTTP_VERSION = 'http2' - assert.equal(new ConnectionConfig().httpVersion, 'http2') - - process.env.E2B_HTTP_VERSION = 'http3' - assert.throws(() => new ConnectionConfig(), /E2B_HTTP_VERSION/) - // An explicit option never consults the environment. - assert.equal( - new ConnectionConfig({ httpVersion: 'http1' }).httpVersion, - 'http1' - ) -}) - -test('httpVersion in args has priority over env var', () => { - process.env.E2B_HTTP_VERSION = 'http1' - assert.equal( - new ConnectionConfig({ httpVersion: 'http2' }).httpVersion, - 'http2' - ) - - // Per-call options and bound options keep the option when merged. - const merged = ConnectionConfig.mergeOpts({ httpVersion: 'http1' }, {}) - assert.equal(new ConnectionConfig(merged).httpVersion, 'http1') -}) - test('debug false in args overrides E2B_DEBUG env var', () => { process.env.E2B_DEBUG = 'true' diff --git a/packages/js-sdk/tests/sandbox/httpVersion.test.ts b/packages/js-sdk/tests/sandbox/httpVersion.test.ts deleted file mode 100644 index 3e7a4187eb..0000000000 --- a/packages/js-sdk/tests/sandbox/httpVersion.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { afterEach, assert, test, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - createEnvdFetch: vi.fn(() => vi.fn()), - createEnvdRpcFetch: vi.fn(() => vi.fn()), -})) - -vi.mock('@connectrpc/connect-web', () => ({ - createConnectTransport: vi.fn(() => ({})), -})) - -vi.mock('../../src/envd/http2', () => ({ - createEnvdFetch: mocks.createEnvdFetch, - createEnvdRpcFetch: mocks.createEnvdRpcFetch, -})) - -afterEach(() => { - vi.clearAllMocks() - delete process.env.E2B_HTTP_VERSION -}) - -async function createSandbox(opts: { - httpVersion?: 'http1' | 'http2' - proxy?: string -}) { - const { ConnectionConfig, Sandbox } = await import('../../src') - const config = new ConnectionConfig(opts) - return new Sandbox({ - ...config, - sandboxId: 'sbx-test', - sandboxDomain: 'sandbox.e2b.dev', - envdVersion: '0.2.4', - envdAccessToken: 'tok', - }) -} - -test('envd fetchers default to HTTP/2', async () => { - await createSandbox({}) - - assert.deepEqual(mocks.createEnvdFetch.mock.calls[0], [undefined, true]) - assert.deepEqual(mocks.createEnvdRpcFetch.mock.calls[0], [undefined, true]) -}) - -test('httpVersion: http1 pins envd HTTP and RPC fetchers to HTTP/1.1', async () => { - await createSandbox({ httpVersion: 'http1', proxy: 'http://127.0.0.1:8080' }) - - assert.deepEqual(mocks.createEnvdFetch.mock.calls[0], [ - 'http://127.0.0.1:8080', - false, - ]) - assert.deepEqual(mocks.createEnvdRpcFetch.mock.calls[0], [ - 'http://127.0.0.1:8080', - false, - ]) -}) - -test('E2B_HTTP_VERSION=http1 pins envd fetchers to HTTP/1.1', async () => { - process.env.E2B_HTTP_VERSION = 'http1' - await createSandbox({}) - - assert.deepEqual(mocks.createEnvdFetch.mock.calls[0], [undefined, false]) - assert.deepEqual(mocks.createEnvdRpcFetch.mock.calls[0], [undefined, false]) -}) diff --git a/packages/python-sdk/README.md b/packages/python-sdk/README.md index 7291532bd4..deb4966eff 100644 --- a/packages/python-sdk/README.md +++ b/packages/python-sdk/README.md @@ -76,16 +76,6 @@ If one process needs more than `16 × 90` concurrent sandbox streams, raise the E2B_ENVD_POOL_SHARDS=32 python eval.py ``` -To avoid HTTP/2 altogether — for example when an intermediary on the path retires long-lived HTTP/2 connections — pin the SDK to HTTP/1.1, which uses one connection per concurrent request: - -```py -from e2b import Sandbox - -sandbox = Sandbox.create(http_version="http1") -``` - -or, for the whole process, `E2B_HTTP_VERSION=http1`. - ### 5. Code execution with Code Interpreter If you need [`run_code()`](https://docs.e2b.dev/code-interpreting/analyze-data-with-ai?utm_source=pypi&utm_medium=referral&utm_campaign=readme&utm_content=e2b), install the [Code Interpreter SDK](https://github.com/e2b-dev/code-interpreter): diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index f8371b895f..ff010de53d 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -32,7 +32,6 @@ from .connection_config import ( ApiParams, ConnectionConfig, - HttpVersion, ProxyTypes, Username, ) @@ -172,7 +171,6 @@ # Connection config "ConnectionConfig", "VolumeConnectionConfig", - "HttpVersion", "ProxyTypes", "ApiParams", "VolumeApiParams", diff --git a/packages/python-sdk/e2b/api/__init__.py b/packages/python-sdk/e2b/api/__init__.py index 76f7c1c640..033c5c5058 100644 --- a/packages/python-sdk/e2b/api/__init__.py +++ b/packages/python-sdk/e2b/api/__init__.py @@ -95,6 +95,7 @@ async def on_response(response: Response) -> None: connection_retries = int(os.getenv("E2B_CONNECTION_RETRIES") or "3") + # Pool tuning for the pyqwest transports, shared by the REST API, envd RPC, # and envd HTTP API stacks. `pool_max_idle_per_host` is per host rather than # the global idle cap the httpx transports took, which suits both: API traffic diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index 9e42b8d8cc..0dfbb782b0 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -266,7 +266,7 @@ def get_envd_httpx_transport( def get_transport( config: ConnectionConfig, - http2: Optional[bool] = None, + http2: bool = True, *, for_streaming: bool = False, ) -> AsyncPyqwestTransport: @@ -275,8 +275,7 @@ def get_transport( TLS connections ALPN negotiates the HTTP version (HTTP/2 against the E2B API), like the http2-enabled httpx transport this replaced. - ``http2`` defaults to the config's ``http_version`` option; ``False`` - returns a separate transport (its own pool) pinned to HTTP/1.1. That + ``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 only resets its stream and the server may never notice, while HTTP/1.1's @@ -294,28 +293,27 @@ def get_transport( return get_httpx_transport( proxy_to_config(config.proxy), READ_TIMEOUT if for_streaming else None, - config.http_version == "http2" if http2 is None else http2, + http2, ) def get_envd_transport( config: ConnectionConfig, - http2: Optional[bool] = None, + http2: bool = True, *, for_streaming: bool = False, ) -> AsyncPyqwestTransport: """The envd HTTP API's transport (file transfers, health checks), on the load-balanced envd pools rather than the control plane's single pool. - ``http2`` defaults to the config's ``http_version`` option; ``False`` - pins the envd traffic to HTTP/1.1 (see :func:`get_transport` for what + ``http2=False`` pins the envd traffic to HTTP/1.1 (see :func:`get_transport` for what that changes). ``for_streaming`` selects the read-timeout-keyed pools, as for :func:`get_transport`. """ return get_envd_httpx_transport( proxy_to_config(config.proxy), READ_TIMEOUT if for_streaming else None, - config.http_version == "http2" if http2 is None else http2, + http2, ) diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index 88494a1c27..179c6dad70 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -271,7 +271,7 @@ def get_envd_httpx_transport( def get_transport( config: ConnectionConfig, - http2: Optional[bool] = None, + http2: bool = True, *, for_streaming: bool = False, ) -> PyqwestTransport: @@ -280,8 +280,7 @@ def get_transport( TLS connections ALPN negotiates the HTTP version (HTTP/2 against the E2B API), like the http2-enabled httpx transport this replaced. - ``http2`` defaults to the config's ``http_version`` option; ``False`` - returns a separate transport (its own pool) pinned to HTTP/1.1. That + ``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 only resets its stream and the server may never notice, while HTTP/1.1's @@ -299,28 +298,27 @@ def get_transport( return get_httpx_transport( proxy_to_config(config.proxy), READ_TIMEOUT if for_streaming else None, - config.http_version == "http2" if http2 is None else http2, + http2, ) def get_envd_transport( config: ConnectionConfig, - http2: Optional[bool] = None, + http2: bool = True, *, for_streaming: bool = False, ) -> PyqwestTransport: """The envd HTTP API's transport (file transfers, health checks), on the load-balanced envd pools rather than the control plane's single pool. - ``http2`` defaults to the config's ``http_version`` option; ``False`` - pins the envd traffic to HTTP/1.1 (see :func:`get_transport` for what + ``http2=False`` pins the envd traffic to HTTP/1.1 (see :func:`get_transport` for what that changes). ``for_streaming`` selects the read-timeout-keyed pools, as for :func:`get_transport`. """ return get_envd_httpx_transport( proxy_to_config(config.proxy), READ_TIMEOUT if for_streaming else None, - config.http_version == "http2" if http2 is None else http2, + http2, ) diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 291b2b30f1..7745080b2e 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -2,7 +2,7 @@ import os import re -from typing import cast, Literal, Mapping, Optional, Dict, TypedDict, Union +from typing import cast, Mapping, Optional, Dict, TypedDict, Union import httpx from typing_extensions import Unpack @@ -39,8 +39,6 @@ KEEPALIVE_PING_INTERVAL_SEC = 50 # 50 seconds KEEPALIVE_PING_HEADER = "Keepalive-Ping-Interval" -HttpVersion = Literal["http1", "http2"] - class ApiParams(TypedDict, total=False): """ @@ -90,14 +88,6 @@ class ApiParams(TypedDict, total=False): sandbox_url: Optional[str] """URL to connect to sandbox, defaults to `E2B_SANDBOX_URL` environment variable.""" - http_version: Optional[HttpVersion] - """HTTP version for requests to the E2B API and to sandboxes (commands, - filesystem, PTY), defaults to `E2B_HTTP_VERSION` environment variable or - `"http2"`. `"http1"` pins them to HTTP/1.1, which uses one connection per - concurrent request instead of multiplexing streams over shared connections - — for example when an intermediary on the path retires or mishandles - long-lived HTTP/2 connections.""" - class ApiParamsWithLogger(ApiParams, total=False): """:class:`ApiParams` plus the construction-time ``logger``. @@ -206,15 +196,6 @@ def _api_url(): def _sandbox_url(): return os.getenv("E2B_SANDBOX_URL") - @staticmethod - def _http_version() -> HttpVersion: - value = (os.getenv("E2B_HTTP_VERSION") or "http2").lower() - if value not in ("http1", "http2"): - raise ValueError( - f"E2B_HTTP_VERSION must be 'http1' or 'http2', got {value!r}" - ) - return cast(HttpVersion, value) - @staticmethod def _get_request_source() -> Optional[str]: source = os.getenv("E2B_USER_AGENT_SOURCE") @@ -262,7 +243,6 @@ def __init__( validate_api_key: Optional[bool] = None, api_url: Optional[str] = None, sandbox_url: Optional[str] = None, - http_version: Optional[HttpVersion] = None, request_timeout: Optional[float] = None, headers: Optional[Dict[str, str]] = None, api_headers: Optional[Dict[str, str]] = None, @@ -304,11 +284,6 @@ def __init__( self._sandbox_url: Optional[str] = ( sandbox_url or ConnectionConfig._sandbox_url() ) - self.http_version: HttpVersion = ( - http_version - if http_version is not None - else ConnectionConfig._http_version() - ) @staticmethod def _get_request_timeout( @@ -387,7 +362,6 @@ def get_api_params( debug = opts.get("debug") proxy = opts.get("proxy") sandbox_url = opts.get("sandbox_url") - http_version = opts.get("http_version") retries = opts.get("retries") req_headers = self.headers.copy() @@ -427,9 +401,6 @@ def get_api_params( if sandbox_url is not None else cast(Optional[str], self._sandbox_url) ), - http_version=( - http_version if http_version is not None else self.http_version - ), logger=self.logger, retries=retries if retries is not None else self.retries, ) diff --git a/packages/python-sdk/e2b/envd/client_async/__init__.py b/packages/python-sdk/e2b/envd/client_async/__init__.py index 9a9b447452..a90e260334 100644 --- a/packages/python-sdk/e2b/envd/client_async/__init__.py +++ b/packages/python-sdk/e2b/envd/client_async/__init__.py @@ -81,10 +81,7 @@ def create_rpc_client( """ http_client = Client( PlainHTTPErrorTransport( - get_envd_pyqwest_transport( - proxy_to_config(config.proxy), - http2=config.http_version == "http2", - ) + get_envd_pyqwest_transport(proxy_to_config(config.proxy)) ) ) return client_cls( diff --git a/packages/python-sdk/e2b/envd/client_sync/__init__.py b/packages/python-sdk/e2b/envd/client_sync/__init__.py index 5a1a36d810..c21392d4b6 100644 --- a/packages/python-sdk/e2b/envd/client_sync/__init__.py +++ b/packages/python-sdk/e2b/envd/client_sync/__init__.py @@ -76,10 +76,7 @@ def create_rpc_client( """ http_client = SyncClient( PlainHTTPErrorTransport( - get_envd_pyqwest_transport( - proxy_to_config(config.proxy), - http2=config.http_version == "http2", - ) + get_envd_pyqwest_transport(proxy_to_config(config.proxy)) ) ) return client_cls( diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index 226be5fd49..ad3af982b4 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -250,33 +250,6 @@ def test_sync_envd_transports_are_shared_across_sandboxes(test_api_key): reset_transport_caches() -def test_sync_transports_follow_the_http_version_option(test_api_key): - reset_transport_caches() - default = sandbox_config(test_api_key, "sbx-0") - http1 = ConnectionConfig(api_key=test_api_key, http_version="http1") - - try: - assert default.http_version == "http2" - assert get_sync_transport(http1) is get_sync_transport(default, http2=False) - assert get_sync_transport(http1) is not get_sync_transport(default) - assert get_sync_envd_transport(default) is get_sync_envd_transport( - default, http2=True - ) - assert get_sync_envd_transport(http1) is get_sync_envd_transport( - default, http2=False - ) - assert get_sync_envd_transport(http1) is not get_sync_envd_transport(default) - # The explicit argument wins over the option. - assert get_sync_envd_transport(http1, http2=True) is get_sync_envd_transport( - default - ) - assert get_sync_envd_api(http1, "https://sandbox.e2b.app")._transport is ( - get_sync_envd_transport(http1) - ) - finally: - reset_transport_caches() - - def test_sync_transports_pass_http_version_to_pyqwest(test_api_key, monkeypatch): # `http_version=None` leaves the version to ALPN (HTTP/2 against the E2B # API), `HTTP1` pins HTTP/1.1. Which version was negotiated is only @@ -517,29 +490,6 @@ async def test_async_envd_transports_are_shared_across_sandboxes(test_api_key): reset_transport_caches() -@pytest.mark.asyncio -async def test_async_transports_follow_the_http_version_option(test_api_key): - reset_transport_caches() - default = sandbox_config(test_api_key, "sbx-0") - http1 = ConnectionConfig(api_key=test_api_key, http_version="http1") - - try: - assert get_async_transport(http1) is get_async_transport(default, http2=False) - assert get_async_transport(http1) is not get_async_transport(default) - assert get_async_envd_transport(http1) is get_async_envd_transport( - default, http2=False - ) - assert get_async_envd_transport(http1) is not get_async_envd_transport(default) - assert get_async_envd_transport(http1, http2=True) is get_async_envd_transport( - default - ) - assert get_async_envd_api(http1, "https://sandbox.e2b.app")._transport is ( - get_async_envd_transport(http1) - ) - finally: - reset_transport_caches() - - @pytest.mark.asyncio async def test_async_transports_pass_http_version_to_pyqwest(test_api_key, monkeypatch): reset_transport_caches() @@ -1236,11 +1186,11 @@ def test_sync_envd_transport_counts_requests_until_their_body_is_done( # a direct RPC — and gives it up exactly once however it ends: fully read, # closed early, timed out, or failed to connect. reset_transport_caches() - config = ConnectionConfig( - api_key=test_api_key, http_version="http2" if http2 else "http1" - ) - envd_api = get_sync_envd_api(config, echo_server) + config = ConnectionConfig(api_key=test_api_key) transport = get_sync_envd_pyqwest_transport(None, http2=http2) + envd_api = httpx.Client( + base_url=echo_server, transport=get_sync_envd_transport(config, http2) + ) balancer = transport.balancer try: @@ -1287,11 +1237,11 @@ async def test_async_envd_transport_counts_requests_until_their_body_is_done( test_api_key, echo_server, http2 ): reset_transport_caches() - config = ConnectionConfig( - api_key=test_api_key, http_version="http2" if http2 else "http1" - ) - envd_api = get_async_envd_api(config, echo_server) + config = ConnectionConfig(api_key=test_api_key) transport = get_async_envd_pyqwest_transport(None, http2=http2) + envd_api = httpx.AsyncClient( + base_url=echo_server, transport=get_async_envd_transport(config, http2) + ) balancer = transport.balancer try: diff --git a/packages/python-sdk/tests/test_connection_config.py b/packages/python-sdk/tests/test_connection_config.py index 5e4f1d3bb3..4241143ac1 100644 --- a/packages/python-sdk/tests/test_connection_config.py +++ b/packages/python-sdk/tests/test_connection_config.py @@ -249,40 +249,3 @@ def test_retries_default_to_three_and_propagate(): def test_retries_reject_invalid_values(retries): with pytest.raises(InvalidArgumentException): ConnectionConfig(retries=retries) - - -def test_http_version_defaults_to_http2_and_propagates(monkeypatch): - monkeypatch.delenv("E2B_HTTP_VERSION", raising=False) - - assert ConnectionConfig().http_version == "http2" - assert ConnectionConfig().get_api_params()["http_version"] == "http2" - - config = ConnectionConfig(http_version="http1") - assert config.http_version == "http1" - # Reconstructed configs (a sandbox's sub-clients) keep the setting, and a - # per-call value overrides it. - assert config.get_api_params()["http_version"] == "http1" - assert ConnectionConfig(**config.get_api_params()).http_version == "http1" - assert config.get_api_params(http_version="http2")["http_version"] == "http2" - - -@pytest.mark.parametrize( - ("value", "expected"), - [("http1", "http1"), ("HTTP1", "http1"), ("http2", "http2"), ("", "http2")], -) -def test_http_version_reads_env_var(monkeypatch, value, expected): - monkeypatch.setenv("E2B_HTTP_VERSION", value) - - assert ConnectionConfig().http_version == expected - # The explicit argument wins over the environment. - other = "http2" if expected == "http1" else "http1" - assert ConnectionConfig(http_version=other).http_version == other - - -def test_http_version_rejects_unknown_env_value(monkeypatch): - monkeypatch.setenv("E2B_HTTP_VERSION", "http3") - - with pytest.raises(ValueError, match="E2B_HTTP_VERSION"): - ConnectionConfig() - # An explicit option never consults the environment. - assert ConnectionConfig(http_version="http1").http_version == "http1" diff --git a/packages/python-sdk/tests/test_envd_client_transport.py b/packages/python-sdk/tests/test_envd_client_transport.py index 7e46593250..9c42aecf5c 100644 --- a/packages/python-sdk/tests/test_envd_client_transport.py +++ b/packages/python-sdk/tests/test_envd_client_transport.py @@ -140,21 +140,18 @@ def test_async_pool_is_cached_per_proxy(): assert api_client_sync.get_pyqwest_transport(None) is not pool_a -@pytest.mark.parametrize("http2", [True, False]) -def test_rpc_clients_run_on_the_shared_envd_transport(test_api_key, monkeypatch, http2): +def test_rpc_clients_run_on_the_shared_envd_transport(test_api_key, monkeypatch): # The RPC stack is the plain-HTTP-error normalization wrapping the very # envd transport the envd httpx clients use, so envd RPC and envd HTTP # calls draw on the same pools and load counts. `pyqwest.SyncClient` # doesn't hand its transport back, so record what the normalization is # given. - config = ConnectionConfig( - api_key=test_api_key, http_version="http2" if http2 else "http1" - ) - pool = api_client_sync.get_envd_pyqwest_transport(None, http2=http2) - async_pool = api_client_async.get_envd_pyqwest_transport(None, http2=http2) + config = ConnectionConfig(api_key=test_api_key) + pool = api_client_sync.get_envd_pyqwest_transport(None) + async_pool = api_client_async.get_envd_pyqwest_transport(None) assert isinstance(pool, api_client_sync.EnvdPoolTransport) assert isinstance(async_pool, api_client_async.EnvdPoolTransport) - assert pool is not api_client_sync.get_envd_pyqwest_transport(None, http2=not http2) + assert pool is not api_client_sync.get_envd_pyqwest_transport(None, http2=False) # The httpx adapters the envd HTTP API uses sit on those same transports. assert api_client_sync.get_envd_transport(config)._transport is pool assert api_client_async.get_envd_transport(config)._transport is async_pool