Skip to content

Commit 2d10853

Browse files
Reflexclaude
andcommitted
feat: default to HTTP/2 transport on Node
Flip the `http2` client option default from `false` to `true`, so the SDK uses its native `node:http2` multiplexing transport by default on Node.js instead of one HTTP/1.1 connection per request. Web/Deno/Bun are unaffected (platform fetch already speaks h2), and a user-supplied `fetch` still wins. Opting out: pass `http2: false` for the HTTP/1.1 `node-fetch` transport. To keep existing `httpAgent` users working, a bare `httpAgent` (with no explicit `http2`) keeps the client on HTTP/1.1 and emits a one-time warning; pass `http2: false` to select HTTP/1.1 explicitly and silence it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6ceb236 commit 2d10853

3 files changed

Lines changed: 109 additions & 31 deletions

File tree

README.md

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -357,29 +357,27 @@ await runloop.devboxes.create({...}, {
357357

358358
### HTTP/2 transport
359359

360-
On Node.js, the SDK can send requests over HTTP/2, which multiplexes many concurrent requests over a small number of TLS connections instead of opening a connection per request. Enable it with the `http2` option:
360+
On Node.js, the SDK sends requests over HTTP/2 **by default**, multiplexing many concurrent requests over a small number of TLS connections instead of opening a connection per request. The transport is built on Node's native `node:http2` and manages a bounded pool of persistent H2 sessions per origin, auto-scaling with load. On the web, Deno, and other runtimes the platform `fetch` already speaks HTTP/2, so this option is a no-op there.
361+
362+
To tune the pool — for example to raise the number of connections for a high-concurrency workload — pass options as `http2`:
361363

362364
<!-- prettier-ignore -->
363365
```ts
364366
const runloop = new RunloopSDK({
365-
http2: true,
367+
http2: { maxConnections: 20 },
366368
});
367369
```
368370

369-
Requests are routed through an [undici](https://github.com/nodejs/undici) connection pool with HTTP/2 enabled, falling back to HTTP/1.1 for origins that don't negotiate h2 via ALPN. It is intended for HTTP/2-capable origins such as the Runloop API. This transport uses undici and therefore **requires Node.js >= 20.18.1** (see Requirements).
370-
371-
`http2: true` uses a default bounded pool. To control the pool yourself — for example to raise the number of connections or multiplexed streams for a high-concurrency workload — pass a configured undici `Dispatcher` (such as an `Agent`) instead. The SDK uses it verbatim and does not manage its lifecycle, the same way it treats a custom `httpAgent`:
371+
To opt out and use the HTTP/1.1 `node-fetch` transport, set `http2: false`:
372372

373373
<!-- prettier-ignore -->
374374
```ts
375-
import { Agent } from 'undici';
376-
377375
const runloop = new RunloopSDK({
378-
http2: new Agent({ allowH2: true, connections: 8, pipelining: 100 }),
376+
http2: false,
379377
});
380378
```
381379

382-
The `httpAgent` option does not apply to the HTTP/2 transport (undici has no Node `http.Agent` concept); set `http2` to a `Dispatcher` to tune connections. A one-time warning is emitted if both `http2` and `httpAgent` are provided.
380+
The `httpAgent` option only applies to the HTTP/1.1 transport — the H2 pool manages its own connections. Passing an `httpAgent` without an explicit `http2` value keeps the client on HTTP/1.1 (with a one-time warning); pass `http2: false` to select HTTP/1.1 explicitly and silence the warning. A custom `fetch` always takes precedence over `http2`.
383381

384382
## Semantic versioning
385383

src/index.ts

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -294,14 +294,20 @@ export interface ClientOptions {
294294
/**
295295
* Send requests over HTTP/2 using native `node:http2` connection pools.
296296
*
297-
* - `true` uses the SDK's default bounded HTTP/2 pool.
297+
* HTTP/2 is the default transport on Node.js: it multiplexes many concurrent
298+
* requests over a small number of TLS connections instead of opening one
299+
* connection per request.
300+
*
301+
* - `true` / omitted uses the SDK's default bounded HTTP/2 pool.
298302
* - Pass `H2FetchOptions` to tune pool size, timeouts, etc.
303+
* - `false` opts out and uses the HTTP/1.1 `node-fetch` transport.
299304
*
300305
* On the HTTP/2 path the `httpAgent` option is not used — the H2 transport
301-
* manages its own persistent connections. A one-time warning is emitted if
302-
* both `http2` and `httpAgent` are set.
306+
* manages its own persistent connections. Passing an `httpAgent` without an
307+
* explicit `http2` value keeps the client on HTTP/1.1 (with a one-time
308+
* warning); pass `http2: false` to opt out silently.
303309
*
304-
* @default false
310+
* @default true
305311
*/
306312
http2?: boolean | import('./lib/h2-transport').H2FetchOptions | undefined;
307313

@@ -341,10 +347,15 @@ export interface ClientOptions {
341347
* console.log(result.exitCode);
342348
* ```
343349
*/
344-
// Emitted at most once per process when `http2` and `httpAgent` are combined (see
345-
// the constructor). Module-scoped flag mirrors the `fileFromPathWarned` pattern in
346-
// _shims/node-runtime.ts.
350+
// Emitted at most once per process each (see the constructor). Module-scoped flags
351+
// mirror the `fileFromPathWarned` pattern in _shims/node-runtime.ts.
352+
//
353+
// `http2HttpAgentWarned` — user explicitly set `http2` *and* `httpAgent`; the agent
354+
// is ignored on the H2 path.
355+
// `http2DefaultAgentWarned`— user set `httpAgent` without an explicit `http2` value, so
356+
// the client stays on HTTP/1.1 rather than the H2 default.
347357
let http2HttpAgentWarned = false;
358+
let http2DefaultAgentWarned = false;
348359

349360
export class Runloop extends Core.APIClient {
350361
bearerToken: string;
@@ -359,7 +370,7 @@ export class Runloop extends Core.APIClient {
359370
* @param {number} [opts.timeout=30 seconds] - The maximum amount of time (in milliseconds) the client will wait for a response before timing out.
360371
* @param {number} [opts.httpAgent] - An HTTP agent used to manage HTTP(s) connections.
361372
* @param {Core.Fetch} [opts.fetch] - Specify a custom `fetch` function implementation.
362-
* @param {boolean | H2FetchOptions} [opts.http2=false] - Send requests over HTTP/2 (Node only; ignored when `fetch` is provided). `true` uses the default bounded pool; pass H2FetchOptions to tune.
373+
* @param {boolean | H2FetchOptions} [opts.http2=true] - Send requests over HTTP/2 (Node only; ignored when `fetch` is provided). Enabled by default; pass `false` to use HTTP/1.1, or H2FetchOptions to tune the pool.
363374
* @param {number} [opts.maxRetries=5] - The maximum number of times the client will retry a request.
364375
* @param {Core.Headers} opts.defaultHeaders - Default headers to include with every request to the API.
365376
* @param {Core.DefaultQuery} opts.defaultQuery - Default query parameters to include with every request to the API.
@@ -381,15 +392,43 @@ export class Runloop extends Core.APIClient {
381392
baseURL: baseURL || `https://api.runloop.ai`,
382393
};
383394

384-
// `httpAgent` does not apply to the HTTP/2 transport — it manages its own
385-
// persistent connections. Warn once instead of silently ignoring.
386-
if (!options.fetch && options.http2 && options.httpAgent && !http2HttpAgentWarned) {
387-
http2HttpAgentWarned = true;
388-
console.warn(
389-
'[runloop] `httpAgent` is ignored when `http2` is set: the HTTP/2 transport manages ' +
390-
'its own connections. To tune the H2 pool, pass options as `http2` ' +
391-
'(e.g. `http2: { maxConnections: 20 }`).',
392-
);
395+
// Resolve the transport. HTTP/2 is the default on Node; a user-supplied
396+
// `fetch` always wins, `http2: false` opts out, and a bare `httpAgent`
397+
// (no explicit `http2`) keeps the client on HTTP/1.1 so existing agents
398+
// keep working. `httpAgent` never applies to the H2 path — it manages its
399+
// own persistent connections.
400+
let useHttp2: boolean;
401+
if (options.fetch) {
402+
// Custom fetch owns the transport entirely.
403+
useHttp2 = false;
404+
} else if (options.http2 === false) {
405+
useHttp2 = false;
406+
} else if (options.http2) {
407+
// Explicit opt-in (`true` or H2FetchOptions).
408+
useHttp2 = true;
409+
if (options.httpAgent && !http2HttpAgentWarned) {
410+
http2HttpAgentWarned = true;
411+
console.warn(
412+
'[runloop] `httpAgent` is ignored when `http2` is set: the HTTP/2 transport manages ' +
413+
'its own connections. To tune the H2 pool, pass options as `http2` ' +
414+
'(e.g. `http2: { maxConnections: 20 }`).',
415+
);
416+
}
417+
} else if (options.httpAgent) {
418+
// Default would be H2, but a `httpAgent` was provided — respect it and
419+
// stay on HTTP/1.1, warning once so the fallback isn't silent.
420+
useHttp2 = false;
421+
if (!http2DefaultAgentWarned) {
422+
http2DefaultAgentWarned = true;
423+
console.warn(
424+
'[runloop] HTTP/2 is the default transport, but `httpAgent` is set, so this client ' +
425+
'uses HTTP/1.1. Remove `httpAgent` to use HTTP/2, or pass `http2: false` to select ' +
426+
'HTTP/1.1 explicitly and silence this warning.',
427+
);
428+
}
429+
} else {
430+
// New default: HTTP/2.
431+
useHttp2 = true;
393432
}
394433

395434
super({
@@ -400,7 +439,7 @@ export class Runloop extends Core.APIClient {
400439
maxRetries: options.maxRetries,
401440
fetch:
402441
options.fetch ??
403-
(options.http2 ?
442+
(useHttp2 ?
404443
makeHttp2Fetch(typeof options.http2 === 'object' ? options.http2 : undefined)
405444
: undefined),
406445
});

tests/index.test.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,8 @@ describe('instantiate client', () => {
9999

100100
test('custom fetch wins over http2', async () => {
101101
// When both `fetch` and `http2` are provided, the custom fetch must be used —
102-
// the undici (h2) adapter should not run. Locks in src/index.ts:
103-
// fetch: options.fetch ?? (options.http2 ? makeHttp2Fetch(...) : undefined)
102+
// the h2 adapter should not run. Locks in src/index.ts:
103+
// fetch: options.fetch ?? (useHttp2 ? makeHttp2Fetch(...) : undefined)
104104
const customFetch = jest.fn((url: RequestInfo) =>
105105
Promise.resolve(
106106
new Response(JSON.stringify({ url, custom: true }), {
@@ -135,13 +135,17 @@ describe('instantiate client', () => {
135135
test('warns once when http2 and httpAgent are combined', () => {
136136
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
137137
try {
138-
// http2 alone (or httpAgent alone) does not warn.
138+
// Explicit http2 alone does not warn.
139139
new Runloop({ baseURL: 'http://localhost:5000/', bearerToken: 'My Bearer Token', http2: true });
140140
expect(warn).not.toHaveBeenCalled();
141141

142142
// Combining them warns — exactly once per process (module-scoped flag), so the
143143
// second construction is silent.
144-
const opts = { baseURL: 'http://localhost:5000/', bearerToken: 'My Bearer Token', httpAgent: {} as any };
144+
const opts = {
145+
baseURL: 'http://localhost:5000/',
146+
bearerToken: 'My Bearer Token',
147+
httpAgent: {} as any,
148+
};
145149
new Runloop({ ...opts, http2: true });
146150
new Runloop({ ...opts, http2: true });
147151
expect(warn).toHaveBeenCalledTimes(1);
@@ -151,6 +155,43 @@ describe('instantiate client', () => {
151155
}
152156
});
153157

158+
test('httpAgent without explicit http2 stays on HTTP/1.1 and warns once', () => {
159+
// HTTP/2 is the default, but a bare `httpAgent` keeps the client on HTTP/1.1 so
160+
// existing agents keep working. The fallback warns once (module-scoped flag).
161+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
162+
try {
163+
const opts = {
164+
baseURL: 'http://localhost:5000/',
165+
bearerToken: 'My Bearer Token',
166+
httpAgent: {} as any,
167+
};
168+
new Runloop(opts);
169+
new Runloop(opts);
170+
expect(warn).toHaveBeenCalledTimes(1);
171+
expect(String(warn.mock.calls[0]?.[0])).toContain('HTTP/1.1');
172+
} finally {
173+
warn.mockRestore();
174+
}
175+
});
176+
177+
test('http2: false opts out of the default HTTP/2 transport without warning', () => {
178+
// Explicit opt-out is silent even alongside an httpAgent — the user has chosen
179+
// HTTP/1.1 deliberately.
180+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
181+
try {
182+
const client = new Runloop({
183+
baseURL: 'http://localhost:5000/',
184+
bearerToken: 'My Bearer Token',
185+
httpAgent: {} as any,
186+
http2: false,
187+
});
188+
expect(client).toBeDefined();
189+
expect(warn).not.toHaveBeenCalled();
190+
} finally {
191+
warn.mockRestore();
192+
}
193+
});
194+
154195
test('explicit global fetch', async () => {
155196
// make sure the global fetch type is assignable to our Fetch type
156197
const client = new Runloop({

0 commit comments

Comments
 (0)