Skip to content

Commit 17ea053

Browse files
committed
bound default agent sockets
1 parent c7af01f commit 17ea053

9 files changed

Lines changed: 120 additions & 22 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,15 @@ This is intended for debugging purposes only and may change in the future withou
340340

341341
By default, this library uses a stable agent for all http/https requests to reuse TCP connections, eliminating many TCP & TLS handshakes and shaving around 100ms off most requests.
342342

343+
The SDK-created default agents cap both active and free sockets at 256 per protocol. Set `httpAgentMaxSockets` to tune that cap for the default agents only:
344+
345+
<!-- prettier-ignore -->
346+
```ts
347+
const runloop = new RunloopSDK({
348+
httpAgentMaxSockets: 512,
349+
});
350+
```
351+
343352
If you would like to disable or customize this behavior, for example to use the API behind a proxy, you can pass an `httpAgent` which is used for all requests (be they http or https), for example:
344353

345354
<!-- prettier-ignore -->
@@ -355,6 +364,8 @@ await runloop.devboxes.create({...}, {
355364
});
356365
```
357366

367+
When `httpAgent` is provided, `httpAgentMaxSockets` is ignored because the custom agent owns its socket policy.
368+
358369
### HTTP/2 transport
359370

360371
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:

src/_shims/index-deno.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export async function getMultipartRequestOptions<T = Record<string, unknown>>(
7979
};
8080
}
8181

82-
export function getDefaultAgent(url: string) {
82+
export function getDefaultAgent(_url: string, _httpAgentMaxSockets?: number) {
8383
return undefined;
8484
}
8585
export function fileFromPath() {

src/_shims/index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export function getMultipartRequestOptions<T = Record<string, unknown>>(
7979
opts: RequestOptions<T>,
8080
): Promise<RequestOptions<T>>;
8181

82-
export function getDefaultAgent(url: string): any;
82+
export function getDefaultAgent(url: string, httpAgentMaxSockets?: number): any;
8383

8484
// @ts-ignore
8585
export type FileFromPathOptions = SelectType<manual.FileFromPathOptions, auto.FileFromPathOptions>;

src/_shims/node-runtime.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { createH2Fetch } from '../lib/h2-transport';
1919
type FileFromPathOptions = Omit<FilePropertyBag, 'lastModified'>;
2020

2121
let fileFromPathWarned = false;
22+
const DEFAULT_HTTP_AGENT_MAX_SOCKETS = 256;
2223

2324
/**
2425
* @deprecated use fs.createReadStream('./my/file.txt') instead
@@ -39,20 +40,26 @@ async function fileFromPath(path: string, ...args: any[]): Promise<File> {
3940
return await _fileFromPath(path, ...args);
4041
}
4142

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-
});
43+
const defaultHttpAgents = new Map<number, Agent>();
44+
const defaultHttpsAgents = new Map<number, Agent>();
45+
46+
function getOrCreateDefaultAgent(url: string, httpAgentMaxSockets = DEFAULT_HTTP_AGENT_MAX_SOCKETS): Agent {
47+
const isHttps = url.startsWith('https');
48+
const cache = isHttps ? defaultHttpsAgents : defaultHttpAgents;
49+
const cached = cache.get(httpAgentMaxSockets);
50+
if (cached) return cached;
51+
52+
const options = {
53+
keepAlive: true,
54+
timeout: 10 * 60 * 1000,
55+
maxSockets: httpAgentMaxSockets,
56+
maxFreeSockets: httpAgentMaxSockets,
57+
freeSocketTimeout: 30_000,
58+
};
59+
const agent: Agent = isHttps ? new KeepAliveAgent.HttpsAgent(options) : new KeepAliveAgent(options);
60+
cache.set(httpAgentMaxSockets, agent);
61+
return agent;
62+
}
5663

5764
async function getMultipartRequestOptions<T = Record<string, unknown>>(
5865
form: fd.FormData,
@@ -88,7 +95,7 @@ export function getRuntime(): Shims {
8895
File: fd.File,
8996
ReadableStream,
9097
getMultipartRequestOptions,
91-
getDefaultAgent: (url: string): Agent => (url.startsWith('https') ? defaultHttpsAgent : defaultHttpAgent),
98+
getDefaultAgent: getOrCreateDefaultAgent,
9299
fileFromPath,
93100
isFsReadStream: (value: any): value is FsReadStream => value instanceof FsReadStream,
94101
};

src/_shims/registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export interface Shims {
2525
form: Shims['FormData'],
2626
opts: RequestOptions<T>,
2727
) => Promise<RequestOptions<T>>;
28-
getDefaultAgent: (url: string) => any;
28+
getDefaultAgent: (url: string, httpAgentMaxSockets?: number) => any;
2929
fileFromPath:
3030
| ((path: string, filename?: string, options?: {}) => Promise<Shims['File']>)
3131
| ((path: string, options?: {}) => Promise<Shims['File']>);

src/_shims/web-runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export function getRuntime({ manuallyImported }: { manuallyImported?: boolean }
9696
...opts,
9797
body: new MultipartBody(form) as any,
9898
}),
99-
getDefaultAgent: (url: string) => undefined,
99+
getDefaultAgent: (_url: string, _httpAgentMaxSockets?: number) => undefined,
100100
fileFromPath: () => {
101101
throw new Error(
102102
'The `fileFromPath` function is only supported in Node. See the README for more details: https://www.github.com/runloopai/api-client-ts#file-uploads',

src/core.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ export abstract class APIClient {
211211
maxRetries: number;
212212
timeout: number;
213213
httpAgent: Agent | undefined;
214+
httpAgentMaxSockets: number;
214215

215216
private fetch: Fetch;
216217
protected idempotencyHeader?: string;
@@ -221,20 +222,23 @@ export abstract class APIClient {
221222
maxRetries = 5,
222223
timeout = 30000, // 30 seconds
223224
httpAgent,
225+
httpAgentMaxSockets = 256,
224226
fetch: overriddenFetch,
225227
}: {
226228
baseURL: string;
227229
baseURLOverridden: boolean;
228230
maxRetries?: number | undefined;
229231
timeout: number | undefined;
230232
httpAgent: Agent | undefined;
233+
httpAgentMaxSockets?: number | undefined;
231234
fetch: Fetch | undefined;
232235
}) {
233236
this.baseURL = baseURL;
234237
this.#baseURLOverridden = baseURLOverridden;
235-
this.maxRetries = validatePositiveInteger('maxRetries', maxRetries);
238+
this.maxRetries = validateNonNegativeInteger('maxRetries', maxRetries);
236239
this.timeout = validatePositiveInteger('timeout', timeout);
237240
this.httpAgent = httpAgent;
241+
this.httpAgentMaxSockets = validatePositiveInteger('httpAgentMaxSockets', httpAgentMaxSockets);
238242

239243
this.fetch = overriddenFetch ?? fetch;
240244
}
@@ -354,7 +358,7 @@ export abstract class APIClient {
354358
const url = this.buildURL(path!, query, defaultBaseURL);
355359
if ('timeout' in options) validatePositiveInteger('timeout', options.timeout);
356360
options.timeout = options.timeout ?? this.timeout;
357-
const httpAgent = options.httpAgent ?? this.httpAgent ?? getDefaultAgent(url);
361+
const httpAgent = options.httpAgent ?? this.httpAgent ?? getDefaultAgent(url, this.httpAgentMaxSockets);
358362
const minAgentTimeout = options.timeout + 1000;
359363
if (
360364
typeof (httpAgent as any)?.options?.timeout === 'number' &&
@@ -1055,12 +1059,22 @@ const validatePositiveInteger = (name: string, n: unknown): number => {
10551059
if (typeof n !== 'number' || !Number.isInteger(n)) {
10561060
throw new RunloopError(`${name} must be an integer`);
10571061
}
1058-
if (n < 0) {
1062+
if (n <= 0) {
10591063
throw new RunloopError(`${name} must be a positive integer`);
10601064
}
10611065
return n;
10621066
};
10631067

1068+
const validateNonNegativeInteger = (name: string, n: unknown): number => {
1069+
if (typeof n !== 'number' || !Number.isInteger(n)) {
1070+
throw new RunloopError(`${name} must be an integer`);
1071+
}
1072+
if (n < 0) {
1073+
throw new RunloopError(`${name} must be a non-negative integer`);
1074+
}
1075+
return n;
1076+
};
1077+
10641078
export const castToError = (err: any): Error => {
10651079
if (err instanceof Error) return err;
10661080
if (typeof err === 'object' && err !== null) {

src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,16 @@ export interface ClientOptions {
283283
*/
284284
httpAgent?: Agent | undefined;
285285

286+
/**
287+
* Maximum active and free sockets for the SDK-created default Node.js HTTP(S) agents.
288+
*
289+
* This only applies when `httpAgent` is not provided. Custom `httpAgent` instances own
290+
* their full socket policy.
291+
*
292+
* @default 256
293+
*/
294+
httpAgentMaxSockets?: number | undefined;
295+
286296
/**
287297
* Specify a custom `fetch` function implementation.
288298
*
@@ -358,6 +368,7 @@ export class Runloop extends Core.APIClient {
358368
* @param {string} [opts.baseURL=process.env['RUNLOOP_BASE_URL'] ?? https://api.runloop.ai] - Override the default base URL for the API.
359369
* @param {number} [opts.timeout=30 seconds] - The maximum amount of time (in milliseconds) the client will wait for a response before timing out.
360370
* @param {number} [opts.httpAgent] - An HTTP agent used to manage HTTP(s) connections.
371+
* @param {number} [opts.httpAgentMaxSockets=256] - Maximum sockets for SDK-created default HTTP(S) agents.
361372
* @param {Core.Fetch} [opts.fetch] - Specify a custom `fetch` function implementation.
362373
* @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.
363374
* @param {number} [opts.maxRetries=5] - The maximum number of times the client will retry a request.
@@ -397,6 +408,7 @@ export class Runloop extends Core.APIClient {
397408
baseURLOverridden: baseURL ? baseURL !== 'https://api.runloop.ai' : false,
398409
timeout: options.timeout ?? 30000 /* 30 seconds */,
399410
httpAgent: options.httpAgent,
411+
httpAgentMaxSockets: options.httpAgentMaxSockets,
400412
maxRetries: options.maxRetries,
401413
fetch:
402414
options.fetch ??

tests/index.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,60 @@ describe('instantiate client', () => {
151151
}
152152
});
153153

154+
describe('httpAgentMaxSockets', () => {
155+
test('bounds SDK-created default agents to 256 sockets', async () => {
156+
const client = new Runloop({
157+
baseURL: 'http://localhost:5000/',
158+
bearerToken: 'My Bearer Token',
159+
});
160+
161+
const { req } = await client.buildRequest({ path: '/foo', method: 'get' });
162+
const agent = (req as any).agent;
163+
164+
expect(agent.maxSockets).toEqual(256);
165+
expect(agent.maxFreeSockets).toEqual(256);
166+
});
167+
168+
test('uses httpAgentMaxSockets for SDK-created default agents', async () => {
169+
const client = new Runloop({
170+
baseURL: 'https://localhost:5000/',
171+
bearerToken: 'My Bearer Token',
172+
httpAgentMaxSockets: 512,
173+
});
174+
175+
const { req } = await client.buildRequest({ path: '/foo', method: 'get' });
176+
const agent = (req as any).agent;
177+
178+
expect(agent.maxSockets).toEqual(512);
179+
expect(agent.maxFreeSockets).toEqual(512);
180+
});
181+
182+
test('custom httpAgent wins over httpAgentMaxSockets', async () => {
183+
const httpAgent = { options: { timeout: 10 * 60 * 1000 } } as any;
184+
const client = new Runloop({
185+
baseURL: 'http://localhost:5000/',
186+
bearerToken: 'My Bearer Token',
187+
httpAgent,
188+
httpAgentMaxSockets: 512,
189+
});
190+
191+
const { req } = await client.buildRequest({ path: '/foo', method: 'get' });
192+
193+
expect((req as any).agent).toBe(httpAgent);
194+
});
195+
196+
test('rejects non-positive httpAgentMaxSockets', () => {
197+
expect(
198+
() =>
199+
new Runloop({
200+
baseURL: 'http://localhost:5000/',
201+
bearerToken: 'My Bearer Token',
202+
httpAgentMaxSockets: 0,
203+
}),
204+
).toThrow('httpAgentMaxSockets must be a positive integer');
205+
});
206+
});
207+
154208
test('explicit global fetch', async () => {
155209
// make sure the global fetch type is assignable to our Fetch type
156210
const client = new Runloop({

0 commit comments

Comments
 (0)