From 7479eb5914aecf0e3e8cd4c802da7c3ddf11f48a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:46:28 +0000 Subject: [PATCH 1/2] fix(js-sdk): send envd file uploads over HTTP/1.1, retire poisoned HTTP/2 dispatchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node's HTTP/2 client charges each queued request body in full against the session's 10 MB memory budget and resets whichever stream's response headers arrive while it is exhausted (NGHTTP2_ENHANCE_YOUR_CALM). Since undici 8 every envd REST request of a process multiplexes over one session, so concurrent or large files.write() uploads reset each other and the reads next to them (SDK-425). files.write() now POSTs /files through the HTTP/1.1 envd fetcher — one request per pooled connection, no shared budget, as undici 7 effectively gave 2.27.1 — while everything else stays on HTTP/2. A dispatcher whose session Node reports as out of memory is retired and replaced; the failed request is not replayed since envd may already have applied it. Co-Authored-By: mish@e2b.dev --- .changeset/envd-uploads-http1.md | 5 + .../js-sdk/src/sandbox/filesystem/index.ts | 18 +++ packages/js-sdk/src/undici.ts | 95 +++++++++++---- .../js-sdk/tests/sandbox/httpVersion.test.ts | 18 +++ packages/js-sdk/tests/undici.test.ts | 113 ++++++++++++++++++ 5 files changed, 224 insertions(+), 25 deletions(-) create mode 100644 .changeset/envd-uploads-http1.md diff --git a/.changeset/envd-uploads-http1.md b/.changeset/envd-uploads-http1.md new file mode 100644 index 0000000000..b6b46cf075 --- /dev/null +++ b/.changeset/envd-uploads-http1.md @@ -0,0 +1,5 @@ +--- +'e2b': patch +--- + +Fix `TypeError: fetch failed` / `NGHTTP2_ENHANCE_YOUR_CALM` on concurrent `files.write()` calls. Node's HTTP/2 client queues each request body in full against a 10 MB per-session memory budget and resets the stream whose response arrives while it is exhausted; since undici 8 every envd request of a process shares one session, so parallel or large uploads reset each other and the reads next to them. `files.write()` uploads now go over HTTP/1.1 (one request per pooled connection, no shared budget), and an envd dispatcher whose HTTP/2 session Node reports as out of memory is retired and replaced instead of failing every later request. Nothing is retried: a failed write may already have been applied. diff --git a/packages/js-sdk/src/sandbox/filesystem/index.ts b/packages/js-sdk/src/sandbox/filesystem/index.ts index b0279c2749..bbc354e0e7 100644 --- a/packages/js-sdk/src/sandbox/filesystem/index.ts +++ b/packages/js-sdk/src/sandbox/filesystem/index.ts @@ -15,6 +15,7 @@ import { Username, wrapStreamWithConnectionCleanup, } from '../../connectionConfig' +import { createEnvdFetch } from '../../envd/http2' import { checkSandboxHealth, @@ -387,11 +388,26 @@ export class Filesystem { private readonly defaultWatchRecursive = false private readonly checkHealth: SandboxHealthCheck + /** + * Transport for `/files` uploads. Node's HTTP/2 client queues each buffered + * request body in full against the session's memory budget (10 MB) and + * resets whichever stream's response headers arrive while it is exhausted + * (ENHANCE_YOUR_CALM), so uploads multiplexed with everything else on the + * shared envd session fail each other and the reads next to them. They go + * over HTTP/1.1 instead: one request per connection, no shared budget. + */ + private readonly uploadFetch: (request: Request) => Promise + constructor( transport: Transport, private readonly envdApi: EnvdApiClient, private readonly connectionConfig: ConnectionConfig ) { + const uploadFetch = createEnvdFetch({ + proxy: connectionConfig.proxy, + httpVersion: '1.1', + }) + this.uploadFetch = (request) => uploadFetch(request) this.rpc = createClient(FilesystemService, transport) this.checkHealth = () => checkSandboxHealth(this.envdApi) } @@ -734,6 +750,7 @@ export class Filesystem { headers, signal, body: {}, + fetch: this.uploadFetch, // Streaming request bodies require half-duplex mode. ...(streamed && { duplex: 'half' as const, @@ -797,6 +814,7 @@ export class Filesystem { writeOpts?.signal ), body: {}, + fetch: this.uploadFetch, }) .catch(async (err) => { throw await handleEnvdApiFetchError(err, this.checkHealth) diff --git a/packages/js-sdk/src/undici.ts b/packages/js-sdk/src/undici.ts index 9dacebd5ea..629ce876b0 100644 --- a/packages/js-sdk/src/undici.ts +++ b/packages/js-sdk/src/undici.ts @@ -9,17 +9,46 @@ type UndiciRequestInit = RequestInit & { duplex?: 'half' } +type UndiciDispatcher = { close(): Promise } + +type UndiciAgentOptions = { + allowH2: boolean + connections?: number +} + export type UndiciModule = { - Agent: new (options: { allowH2: boolean; connections?: number }) => unknown - ProxyAgent: new (options: { - uri: string - allowH2: boolean - connections?: number - proxyTunnel: true - }) => unknown + Agent: new (options: UndiciAgentOptions) => UndiciDispatcher + ProxyAgent: new ( + options: UndiciAgentOptions & { uri: string; proxyTunnel: true } + ) => UndiciDispatcher fetch: unknown } +// Node's HTTP/2 client charges queued request-body bytes against a per-session +// memory budget (`maxSessionMemory`, 10 MB) and resets the stream whose +// response headers arrive while the budget is exhausted — with +// ENHANCE_YOUR_CALM, or INTERNAL_ERROR where the reset is deferred. Bytes of +// streams destroyed before they were flushed stay charged until the session +// closes (nodejs/node#67113), so such a reset marks the session as poisoned. +const POISONED_HTTP2_SESSION_RST = + /NGHTTP2_(ENHANCE_YOUR_CALM|INTERNAL_ERROR)\b/ + +/** + * Whether a `fetch` rejection is Node's HTTP/2 client resetting the stream + * itself because the session's memory budget is exhausted. The session stays + * unusable for every later request, so the dispatcher holding it must go. + */ +export function isPoisonedHTTP2SessionError(err: unknown): boolean { + if (!(err instanceof Error) || !(err.cause instanceof Error)) { + return false + } + const cause = err.cause as Error & { code?: unknown } + return ( + cause.code === 'ERR_HTTP2_STREAM_ERROR' && + POISONED_HTTP2_SESSION_RST.test(cause.message) + ) +} + const UNDICI_MIN_NODE = '22.19.0' export async function loadUndici( @@ -84,7 +113,9 @@ export function createRuntimeFetch( /** * Build a fetch bound to a bounded undici dispatcher (HTTP/2 enabled unless * `httpVersion` is `'1.1'`, `connections` origin connections, optional proxy - * tunnel). Falls back to the global fetch when undici cannot be loaded. + * tunnel). A dispatcher whose HTTP/2 session Node reports as out of memory is + * retired and replaced, so one poisoned session does not fail every later + * request. Falls back to the global fetch when undici cannot be loaded. */ export async function buildDispatchedFetch(options: { connections: number @@ -99,30 +130,44 @@ export async function buildDispatchedFetch(options: { } const { Agent, ProxyAgent, fetch: undiciFetch } = undici - const allowH2 = (options.httpVersion ?? DEFAULT_HTTP_VERSION) === '2' - const dispatcher = options.proxy - ? new ProxyAgent({ - uri: options.proxy, - allowH2, - connections: options.connections, - proxyTunnel: true, - }) - : new Agent({ - allowH2, - connections: options.connections, - }) + const agentOptions: UndiciAgentOptions = { + allowH2: (options.httpVersion ?? DEFAULT_HTTP_VERSION) === '2', + connections: options.connections, + } + const createDispatcher = () => + options.proxy + ? new ProxyAgent({ + uri: options.proxy, + ...agentOptions, + proxyTunnel: true, + }) + : new Agent(agentOptions) const fetchWithDispatcher = undiciFetch as unknown as ( input: RequestInfo | URL, init?: UndiciRequestInit ) => Promise - return ((input, init) => { + let dispatcher = createDispatcher() + + return (async (input, init) => { const request = toUndiciRequestInput(input, init) + const used = dispatcher - return fetchWithDispatcher(request.input, { - ...request.init, - dispatcher, - }) + try { + return await fetchWithDispatcher(request.input, { + ...request.init, + dispatcher: used, + }) + } catch (err) { + // Only the first failure of a dispatcher replaces it; its other in-flight + // requests fail the same way and must not retire the replacement. + if (used === dispatcher && isPoisonedHTTP2SessionError(err)) { + dispatcher = createDispatcher() + // Graceful: requests still running on the old session finish first. + void used.close().catch(() => {}) + } + throw err + } }) as typeof fetch } diff --git a/packages/js-sdk/tests/sandbox/httpVersion.test.ts b/packages/js-sdk/tests/sandbox/httpVersion.test.ts index d01d48e0b7..40ff1b69fd 100644 --- a/packages/js-sdk/tests/sandbox/httpVersion.test.ts +++ b/packages/js-sdk/tests/sandbox/httpVersion.test.ts @@ -73,3 +73,21 @@ test('E2B_HTTP_VERSION=1.1 pins envd fetchers to HTTP/1.1', async () => { httpVersion: '1.1', }) }) + +test('files.write uploads use an HTTP/1.1 envd fetcher regardless of httpVersion', async () => { + await createSandbox({ httpVersion: '2', proxy: 'http://127.0.0.1:8080' }) + + const envdFetchCalls = mocks.createEnvdFetch.mock.calls.map( + (call) => call[0] as { proxy?: string; httpVersion?: string } + ) + expect(envdFetchCalls).toContainEqual( + expect.objectContaining({ + proxy: 'http://127.0.0.1:8080', + httpVersion: '2', + }) + ) + expect(envdFetchCalls).toContainEqual({ + proxy: 'http://127.0.0.1:8080', + httpVersion: '1.1', + }) +}) diff --git a/packages/js-sdk/tests/undici.test.ts b/packages/js-sdk/tests/undici.test.ts index e63d607c7d..9264b9dc2d 100644 --- a/packages/js-sdk/tests/undici.test.ts +++ b/packages/js-sdk/tests/undici.test.ts @@ -3,6 +3,7 @@ import { expect, test, vi } from 'vitest' import { buildDispatchedFetch, createRuntimeFetch, + isPoisonedHTTP2SessionError, loadUndici, type UndiciModule, } from '../src/undici' @@ -209,3 +210,115 @@ test.skipIf(runtime !== 'node')( } } ) + +function poisonedSessionError(rst = 'NGHTTP2_ENHANCE_YOUR_CALM'): TypeError { + return new TypeError('fetch failed', { + cause: Object.assign(new Error(`Stream closed with error code ${rst}`), { + code: 'ERR_HTTP2_STREAM_ERROR', + }), + }) +} + +test.each([ + ['NGHTTP2_ENHANCE_YOUR_CALM', true], + ['NGHTTP2_INTERNAL_ERROR', true], + ['NGHTTP2_CANCEL', false], + ['NGHTTP2_REFUSED_STREAM', false], +])('isPoisonedHTTP2SessionError on a %s stream reset is %s', (rst, poisoned) => + expect(isPoisonedHTTP2SessionError(poisonedSessionError(rst))).toBe(poisoned) +) + +test('isPoisonedHTTP2SessionError ignores other transport failures', () => { + expect( + isPoisonedHTTP2SessionError( + new TypeError('fetch failed', { + cause: Object.assign(new Error('read ECONNRESET'), { + code: 'ECONNRESET', + }), + }) + ) + ).toBe(false) + expect(isPoisonedHTTP2SessionError(new TypeError('fetch failed'))).toBe(false) + expect(isPoisonedHTTP2SessionError('fetch failed')).toBe(false) +}) + +function fakeUndiciWithDispatchers(responses: Array<() => Promise>) { + const agents: Array<{ options: unknown; close: ReturnType }> = + [] + const dispatched: unknown[] = [] + + class Agent { + close = vi.fn(() => Promise.resolve()) + constructor(options: unknown) { + agents.push({ options, close: this.close }) + } + } + + const undici = { + Agent, + ProxyAgent: class extends Agent {}, + fetch: (_input: unknown, init?: { dispatcher?: unknown }) => { + dispatched.push(init?.dispatcher) + return responses.shift()!() + }, + } as unknown as UndiciModule + + return { agents, dispatched, undici } +} + +test('retires the dispatcher once after its HTTP/2 session is poisoned', async () => { + const { agents, dispatched, undici } = fakeUndiciWithDispatchers([ + () => Promise.reject(poisonedSessionError()), + () => Promise.reject(poisonedSessionError()), + () => Promise.resolve(new Response('ok')), + ]) + + const fetcher = await buildDispatchedFetch({ + connections: 1, + loadUndici: () => Promise.resolve(undici), + }) + + // Two requests in flight on the same poisoned session: both fail, the + // dispatcher is replaced once, and the replacement is not closed in turn. + const results = await Promise.allSettled([ + fetcher('https://example.com/a'), + fetcher('https://example.com/b'), + ]) + expect(results.map((r) => r.status)).toEqual(['rejected', 'rejected']) + expect(agents).toHaveLength(2) + expect(agents[0].close).toHaveBeenCalledOnce() + expect(agents[1].close).not.toHaveBeenCalled() + + const res = await fetcher('https://example.com/c') + expect(await res.text()).toBe('ok') + expect(dispatched).toEqual( + [agents[0], agents[0], agents[1]].map(() => expect.any(Object)) + ) + expect(dispatched[0]).toBe(dispatched[1]) + expect(dispatched[2]).not.toBe(dispatched[0]) + expect(dispatched[2]).toBeInstanceOf(undici.Agent) +}) + +test('keeps the dispatcher on other fetch failures', async () => { + const { agents, undici } = fakeUndiciWithDispatchers([ + () => + Promise.reject( + new TypeError('fetch failed', { + cause: Object.assign(new Error('read ECONNRESET'), { + code: 'ECONNRESET', + }), + }) + ), + () => Promise.resolve(new Response('ok')), + ]) + + const fetcher = await buildDispatchedFetch({ + connections: 1, + loadUndici: () => Promise.resolve(undici), + }) + + await expect(fetcher('https://example.com/')).rejects.toThrow('fetch failed') + await fetcher('https://example.com/') + expect(agents).toHaveLength(1) + expect(agents[0].close).not.toHaveBeenCalled() +}) From 8256418f0f7c6bc3793adc9686a7e22128d2a91c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:08:35 +0000 Subject: [PATCH 2/2] fix(js-sdk): dedicated HTTP/1.1 pool for envd uploads, document the pin Uploads shared the 10-connection envd REST pool, so more than ten concurrent files.write() calls in a process queued in undici while their request timeout kept running. They now get a pool of their own (E2B_ENVD_UPLOAD_CONNECTIONS, default 50). The ConnectionOpts.httpVersion docs state the upload exception. Co-Authored-By: mish@e2b.dev --- .changeset/envd-uploads-http1.md | 2 +- packages/js-sdk/src/connectionConfig.ts | 5 ++- packages/js-sdk/src/envd/http2.ts | 39 +++++++++++++++++++ .../js-sdk/src/sandbox/filesystem/index.ts | 16 ++------ packages/js-sdk/tests/envd/http2.test.ts | 14 +++++++ .../js-sdk/tests/sandbox/httpVersion.test.ts | 19 ++++----- .../js-sdk/tests/sandbox/rpcHeaders.test.ts | 1 + 7 files changed, 69 insertions(+), 27 deletions(-) diff --git a/.changeset/envd-uploads-http1.md b/.changeset/envd-uploads-http1.md index b6b46cf075..465eafd2c9 100644 --- a/.changeset/envd-uploads-http1.md +++ b/.changeset/envd-uploads-http1.md @@ -2,4 +2,4 @@ 'e2b': patch --- -Fix `TypeError: fetch failed` / `NGHTTP2_ENHANCE_YOUR_CALM` on concurrent `files.write()` calls. Node's HTTP/2 client queues each request body in full against a 10 MB per-session memory budget and resets the stream whose response arrives while it is exhausted; since undici 8 every envd request of a process shares one session, so parallel or large uploads reset each other and the reads next to them. `files.write()` uploads now go over HTTP/1.1 (one request per pooled connection, no shared budget), and an envd dispatcher whose HTTP/2 session Node reports as out of memory is retired and replaced instead of failing every later request. Nothing is retried: a failed write may already have been applied. +Fix `TypeError: fetch failed` / `NGHTTP2_ENHANCE_YOUR_CALM` on concurrent `files.write()` calls. Node's HTTP/2 client queues each request body in full against a 10 MB per-session memory budget and resets the stream whose response arrives while it is exhausted; since undici 8 every envd request of a process shares one session, so parallel or large uploads reset each other and the reads next to them. `files.write()` uploads now go over HTTP/1.1 on a pool of their own (one request per connection, no shared budget; `E2B_ENVD_UPLOAD_CONNECTIONS`, default 50), and an envd dispatcher whose HTTP/2 session Node reports as out of memory is retired and replaced instead of failing every later request. Nothing is retried: a failed write may already have been applied. diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index 0cb5ea3ba1..a02aa453df 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -138,7 +138,10 @@ export interface ConnectionOpts { * filesystem, PTY). `'2'` multiplexes streams over shared connections; * `'1.1'` 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. + * mishandles long-lived HTTP/2 connections. File uploads (`files.write()`) + * always use HTTP/1.1, whatever this option says, so that their bodies + * cannot exhaust Node's per-session HTTP/2 memory and reset other requests. + * Only applies in Node. * * @default E2B_HTTP_VERSION // environment variable or `'2'` */ diff --git a/packages/js-sdk/src/envd/http2.ts b/packages/js-sdk/src/envd/http2.ts index 312da43dab..d14325b909 100644 --- a/packages/js-sdk/src/envd/http2.ts +++ b/packages/js-sdk/src/envd/http2.ts @@ -19,8 +19,10 @@ type EnvdFetchOptions = { // keep sharing a single dispatcher while each distinct proxy URL gets its own. const envdFetchers = new Map() const envdRpcFetchers = new Map() +const envdUploadFetchers = new Map() const DEFAULT_ENVD_CONNECTION_LIMIT = 10 const DEFAULT_ENVD_RPC_CONNECTION_LIMIT = 200 +const DEFAULT_ENVD_UPLOAD_CONNECTION_LIMIT = 50 export function createEnvdFetchForRuntime( currentRuntime = runtime, @@ -92,3 +94,40 @@ export function getEnvdRpcConnectionLimit(): number { DEFAULT_ENVD_RPC_CONNECTION_LIMIT ) } + +/** + * Fetch for `/files` uploads, always HTTP/1.1. Node's HTTP/2 client queues + * each buffered request body in full against the session's memory budget + * (`maxSessionMemory`, 10 MB) and resets whichever stream's response headers + * arrive while it is exhausted (ENHANCE_YOUR_CALM), so uploads multiplexed + * with everything else on the shared envd session fail each other and the + * reads next to them. HTTP/1.1 gives each upload a connection of its own; the + * pool is sized for many concurrent uploads per process since the request + * timeout keeps running while a request waits for a free connection. + */ +export function createEnvdUploadFetch({ + proxy, +}: Pick = {}): typeof fetch { + const key = fetcherKey(proxy, '1.1') + + const cached = envdUploadFetchers.get(key) + if (cached) { + return cached + } + + const envdUploadFetch = createEnvdFetchForRuntime(runtime, { + connectionLimit: getEnvdUploadConnectionLimit(), + proxy, + httpVersion: '1.1', + }) + envdUploadFetchers.set(key, envdUploadFetch) + + return envdUploadFetch +} + +export function getEnvdUploadConnectionLimit(): number { + return parsePositiveIntEnv( + 'E2B_ENVD_UPLOAD_CONNECTIONS', + DEFAULT_ENVD_UPLOAD_CONNECTION_LIMIT + ) +} diff --git a/packages/js-sdk/src/sandbox/filesystem/index.ts b/packages/js-sdk/src/sandbox/filesystem/index.ts index bbc354e0e7..9e0e1b38d9 100644 --- a/packages/js-sdk/src/sandbox/filesystem/index.ts +++ b/packages/js-sdk/src/sandbox/filesystem/index.ts @@ -15,7 +15,7 @@ import { Username, wrapStreamWithConnectionCleanup, } from '../../connectionConfig' -import { createEnvdFetch } from '../../envd/http2' +import { createEnvdUploadFetch } from '../../envd/http2' import { checkSandboxHealth, @@ -388,14 +388,7 @@ export class Filesystem { private readonly defaultWatchRecursive = false private readonly checkHealth: SandboxHealthCheck - /** - * Transport for `/files` uploads. Node's HTTP/2 client queues each buffered - * request body in full against the session's memory budget (10 MB) and - * resets whichever stream's response headers arrive while it is exhausted - * (ENHANCE_YOUR_CALM), so uploads multiplexed with everything else on the - * shared envd session fail each other and the reads next to them. They go - * over HTTP/1.1 instead: one request per connection, no shared budget. - */ + /** `/files` uploads go over HTTP/1.1, see {@link createEnvdUploadFetch}. */ private readonly uploadFetch: (request: Request) => Promise constructor( @@ -403,10 +396,7 @@ export class Filesystem { private readonly envdApi: EnvdApiClient, private readonly connectionConfig: ConnectionConfig ) { - const uploadFetch = createEnvdFetch({ - proxy: connectionConfig.proxy, - httpVersion: '1.1', - }) + const uploadFetch = createEnvdUploadFetch({ proxy: connectionConfig.proxy }) this.uploadFetch = (request) => uploadFetch(request) this.rpc = createClient(FilesystemService, transport) this.checkHealth = () => checkSandboxHealth(this.envdApi) diff --git a/packages/js-sdk/tests/envd/http2.test.ts b/packages/js-sdk/tests/envd/http2.test.ts index 022673196b..841f8d3d28 100644 --- a/packages/js-sdk/tests/envd/http2.test.ts +++ b/packages/js-sdk/tests/envd/http2.test.ts @@ -4,6 +4,7 @@ afterEach(() => { vi.restoreAllMocks() vi.resetModules() delete process.env.E2B_ENVD_RPC_CONNECTIONS + delete process.env.E2B_ENVD_UPLOAD_CONNECTIONS }) test('uses undici with HTTP/2 enabled in Node', async () => { @@ -297,3 +298,16 @@ test('uses global fetch outside Node', async () => { undefined ) }) + +test('getEnvdUploadConnectionLimit defaults to 50 and honours E2B_ENVD_UPLOAD_CONNECTIONS', async () => { + const { getEnvdUploadConnectionLimit } = await import('../../src/envd/http2') + expect(getEnvdUploadConnectionLimit()).toBe(50) + + process.env.E2B_ENVD_UPLOAD_CONNECTIONS = '8' + expect(getEnvdUploadConnectionLimit()).toBe(8) + + process.env.E2B_ENVD_UPLOAD_CONNECTIONS = 'bogus' + expect(() => getEnvdUploadConnectionLimit()).toThrow( + /E2B_ENVD_UPLOAD_CONNECTIONS/ + ) +}) diff --git a/packages/js-sdk/tests/sandbox/httpVersion.test.ts b/packages/js-sdk/tests/sandbox/httpVersion.test.ts index 40ff1b69fd..ec70a5137e 100644 --- a/packages/js-sdk/tests/sandbox/httpVersion.test.ts +++ b/packages/js-sdk/tests/sandbox/httpVersion.test.ts @@ -3,6 +3,7 @@ import { afterEach, expect, test, vi } from 'vitest' const mocks = vi.hoisted(() => ({ createEnvdFetch: vi.fn(() => vi.fn()), createEnvdRpcFetch: vi.fn(() => vi.fn()), + createEnvdUploadFetch: vi.fn(() => vi.fn()), })) vi.mock('@connectrpc/connect-web', () => ({ @@ -12,6 +13,7 @@ vi.mock('@connectrpc/connect-web', () => ({ vi.mock('../../src/envd/http2', () => ({ createEnvdFetch: mocks.createEnvdFetch, createEnvdRpcFetch: mocks.createEnvdRpcFetch, + createEnvdUploadFetch: mocks.createEnvdUploadFetch, })) afterEach(() => { @@ -74,20 +76,13 @@ test('E2B_HTTP_VERSION=1.1 pins envd fetchers to HTTP/1.1', async () => { }) }) -test('files.write uploads use an HTTP/1.1 envd fetcher regardless of httpVersion', async () => { +test('files.write uploads get the HTTP/1.1 upload fetcher for the configured proxy', async () => { await createSandbox({ httpVersion: '2', proxy: 'http://127.0.0.1:8080' }) - const envdFetchCalls = mocks.createEnvdFetch.mock.calls.map( - (call) => call[0] as { proxy?: string; httpVersion?: string } - ) - expect(envdFetchCalls).toContainEqual( - expect.objectContaining({ - proxy: 'http://127.0.0.1:8080', - httpVersion: '2', - }) - ) - expect(envdFetchCalls).toContainEqual({ + expect(mocks.createEnvdUploadFetch).toHaveBeenCalledWith({ proxy: 'http://127.0.0.1:8080', - httpVersion: '1.1', + }) + expect(mocks.createEnvdFetch.mock.calls[0][0]).toMatchObject({ + httpVersion: '2', }) }) diff --git a/packages/js-sdk/tests/sandbox/rpcHeaders.test.ts b/packages/js-sdk/tests/sandbox/rpcHeaders.test.ts index a559713d38..c8faf02d37 100644 --- a/packages/js-sdk/tests/sandbox/rpcHeaders.test.ts +++ b/packages/js-sdk/tests/sandbox/rpcHeaders.test.ts @@ -15,6 +15,7 @@ vi.mock('@connectrpc/connect-web', () => ({ vi.mock('../../src/envd/http2', () => ({ createEnvdFetch: vi.fn(() => vi.fn()), createEnvdRpcFetch: vi.fn(() => mocks.rpcFetch), + createEnvdUploadFetch: vi.fn(() => vi.fn()), })) test('does not pass custom connection headers to envd RPC requests', async () => {