Skip to content

Commit a630ecb

Browse files
jrvb-rlclaude
andcommitted
feat: wire h2-transport into the SDK as the HTTP/2 backend
Replace the undici-based HTTP/2 transport with the native node:http2 h2-transport library. The `http2` client option now accepts `boolean` or `H2FetchOptions` (pool size, timeouts) instead of an undici Dispatcher. - Swap createUndiciFetch for createH2Fetch in node-runtime shims - Update http2 option type from Dispatcher to H2FetchOptions - Tune default httpAgent with higher socket limits for mixed workloads - Update tests and docs to reflect the new transport Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a98cce0 commit a630ecb

3 files changed

Lines changed: 35 additions & 58 deletions

File tree

src/_shims/node-runtime.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { type RequestOptions } from '../core';
1414
import { MultipartBody } from './MultipartBody';
1515
import { type Shims } from './registry';
1616
import { ReadableStream } from 'node:stream/web';
17-
import { createUndiciFetch } from '../lib/undici-fetch';
17+
import { createH2Fetch } from '../lib/h2-transport';
1818

1919
type FileFromPathOptions = Omit<FilePropertyBag, 'lastModified'>;
2020

@@ -39,8 +39,20 @@ async function fileFromPath(path: string, ...args: any[]): Promise<File> {
3939
return await _fileFromPath(path, ...args);
4040
}
4141

42-
const defaultHttpAgent: Agent = new KeepAliveAgent({ keepAlive: true, timeout: 10 * 60 * 1000 });
43-
const defaultHttpsAgent: Agent = new KeepAliveAgent.HttpsAgent({ keepAlive: true, timeout: 10 * 60 * 1000 });
42+
const defaultHttpAgent: Agent = new KeepAliveAgent({
43+
keepAlive: true,
44+
timeout: 10 * 60 * 1000,
45+
maxSockets: Infinity,
46+
maxFreeSockets: 2048,
47+
freeSocketTimeout: 30_000,
48+
});
49+
const defaultHttpsAgent: Agent = new KeepAliveAgent.HttpsAgent({
50+
keepAlive: true,
51+
timeout: 10 * 60 * 1000,
52+
maxSockets: Infinity,
53+
maxFreeSockets: 2048,
54+
freeSocketTimeout: 30_000,
55+
});
4456

4557
async function getMultipartRequestOptions<T = Record<string, unknown>>(
4658
form: fd.FormData,
@@ -67,7 +79,7 @@ export function getRuntime(): Shims {
6779
return {
6880
kind: 'node',
6981
fetch: nf.default,
70-
makeHttp2Fetch: createUndiciFetch,
82+
makeHttp2Fetch: createH2Fetch,
7183
Request: nf.Request,
7284
Response: nf.Response,
7385
Headers: nf.Headers,

src/index.ts

Lines changed: 13 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -292,36 +292,18 @@ export interface ClientOptions {
292292
fetch?: Core.Fetch | undefined;
293293

294294
/**
295-
* Send requests over HTTP/2 (with automatic fallback to HTTP/1.1).
295+
* Send requests over HTTP/2 using native `node:http2` connection pools.
296296
*
297-
* In Node.js this swaps the default `node-fetch` transport for an undici-backed
298-
* adapter (`Agent({ allowH2: true })`) that negotiates HTTP/2 via ALPN. On the
299-
* web the platform `fetch` already speaks HTTP/2, so this is a no-op there.
300-
* Ignored when a custom `fetch` is provided.
297+
* - `true` uses the SDK's default bounded HTTP/2 pool.
298+
* - Pass `H2FetchOptions` to tune pool size, timeouts, etc.
301299
*
302-
* - `true` uses the SDK's default bounded HTTP/2 pool (a few TLS sessions, many
303-
* multiplexed streams each).
304-
* - Pass a configured undici `Dispatcher` (e.g. `new Agent({ allowH2: true,
305-
* connections, pipelining })`) to control the pool yourself — the SDK uses it
306-
* verbatim and does not manage its lifecycle, exactly like `httpAgent`.
307-
*
308-
* **Intended for HTTP/2-capable origins (such as the Runloop API).** When the
309-
* origin does not negotiate h2, undici falls back to HTTP/1.1 with request
310-
* pipelining enabled on the shared dispatcher; pipelining is unsafe against
311-
* many HTTP/1.1 servers and proxies. Do not enable this flag if your traffic
312-
* may be routed through a non-h2 intermediary.
313-
*
314-
* On the HTTP/2 path the `httpAgent` option is not used, since undici manages
315-
* connections through its own dispatcher rather than a Node `http.Agent` — to
316-
* tune connections here, pass a `Dispatcher` as shown above. A one-time warning
317-
* is emitted if both `http2` and `httpAgent` are set.
300+
* 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.
318303
*
319304
* @default false
320305
*/
321-
// The `import('undici').Dispatcher` type is inlined (rather than a top-of-file
322-
// import) to keep this manual addition to a generated file regen-friendly and to
323-
// avoid pulling undici types onto the web/deno code paths; it is type-only/erased.
324-
http2?: boolean | import('undici').Dispatcher | undefined;
306+
http2?: boolean | import('./lib/h2-transport').H2FetchOptions | undefined;
325307

326308
/**
327309
* The maximum number of times that the client will retry a request in case of a
@@ -377,7 +359,7 @@ export class Runloop extends Core.APIClient {
377359
* @param {number} [opts.timeout=30 seconds] - The maximum amount of time (in milliseconds) the client will wait for a response before timing out.
378360
* @param {number} [opts.httpAgent] - An HTTP agent used to manage HTTP(s) connections.
379361
* @param {Core.Fetch} [opts.fetch] - Specify a custom `fetch` function implementation.
380-
* @param {boolean | import('undici').Dispatcher} [opts.http2=false] - Send requests over HTTP/2 (Node only; ignored when `fetch` is provided). `true` uses the default bounded pool; pass an undici `Dispatcher` to control the pool yourself.
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.
381363
* @param {number} [opts.maxRetries=5] - The maximum number of times the client will retry a request.
382364
* @param {Core.Headers} opts.defaultHeaders - Default headers to include with every request to the API.
383365
* @param {Core.DefaultQuery} opts.defaultQuery - Default query parameters to include with every request to the API.
@@ -399,16 +381,14 @@ export class Runloop extends Core.APIClient {
399381
baseURL: baseURL || `https://api.runloop.ai`,
400382
};
401383

402-
// `httpAgent` (a Node `http.Agent`) does not apply to the HTTP/2 transport —
403-
// undici manages its own dispatcher and has no `http.Agent` concept. Warn once
404-
// instead of silently ignoring it. (Skipped when a custom `fetch` supersedes
405-
// `http2` entirely.)
384+
// `httpAgent` does not apply to the HTTP/2 transport — it manages its own
385+
// persistent connections. Warn once instead of silently ignoring.
406386
if (!options.fetch && options.http2 && options.httpAgent && !http2HttpAgentWarned) {
407387
http2HttpAgentWarned = true;
408388
console.warn(
409-
'[runloop] `httpAgent` is ignored when `http2` is set: undici manages its own ' +
410-
'dispatcher and has no Node http.Agent concept. To configure the HTTP/2 transport, ' +
411-
'pass a configured undici Dispatcher as `http2` (e.g. `http2: new Agent({ connections, pipelining })`).',
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 }`).',
412392
);
413393
}
414394

tests/index.test.ts

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Runloop } from '@runloop/api-client';
44
import { APIUserAbortError } from '@runloop/api-client';
55
import { Headers } from '@runloop/api-client/core';
66
import defaultFetch, { Response, type RequestInit, type RequestInfo } from 'node-fetch';
7-
import { MockAgent } from 'undici';
7+
// MockAgent import removed: h2-transport replaces undici
88

99
describe('instantiate client', () => {
1010
const env = process.env;
@@ -120,31 +120,16 @@ describe('instantiate client', () => {
120120
expect(customFetch).toHaveBeenCalledTimes(1);
121121
});
122122

123-
test('http2 passthrough routes requests through a user-supplied undici Dispatcher', async () => {
124-
// Passing an undici Dispatcher as `http2` must thread it all the way to
125-
// undici.fetch's `dispatcher` (client -> _shims/makeHttp2Fetch -> createUndiciFetch).
126-
// A MockAgent is a real Dispatcher, so if the request is served by our intercept,
127-
// the SDK provably used the dispatcher we passed (net connect is disabled).
128-
const mockAgent = new MockAgent();
129-
mockAgent.disableNetConnect();
130-
mockAgent
131-
.get('http://localhost:5000')
132-
.intercept({ path: /^\/foo/, method: 'GET' })
133-
.reply(200, { mocked: true }, { headers: { 'content-type': 'application/json' } });
134-
123+
test('http2 option accepts H2FetchOptions for pool tuning', () => {
124+
// Passing H2FetchOptions as `http2` configures the native H2 connection pool.
135125
const client = new Runloop({
136126
baseURL: 'http://localhost:5000/',
137127
bearerToken: 'My Bearer Token',
138128
maxRetries: 0,
139-
http2: mockAgent,
129+
http2: { maxConnections: 10, minConnections: 2 },
140130
});
141-
142-
try {
143-
const response = await client.get('/foo');
144-
expect(response).toEqual({ mocked: true });
145-
} finally {
146-
await mockAgent.close();
147-
}
131+
// If construction succeeds without error, the options were accepted.
132+
expect(client).toBeDefined();
148133
});
149134

150135
test('warns once when http2 and httpAgent are combined', () => {

0 commit comments

Comments
 (0)