Skip to content

Commit e62863c

Browse files
authored
Synchronously update internal sockets length so http.Agent pooling is used (#300)
* Synchronously update internal sockets length so http.Agent pooling is used Fixes #299 * Fix some comments * Use fewer ts-expect-error comments * Implement getName method to get both http/https names * Fix linting * Create seven-colts-flash.md * Make getName() public and add tests for it * Add test for keepAlive+maxSockets when using https
1 parent b5f94e3 commit e62863c

File tree

3 files changed

+199
-8
lines changed

3 files changed

+199
-8
lines changed

.changeset/seven-colts-flash.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agent-base": patch
3+
---
4+
5+
Synchronously update internal sockets length so `http.Agent` pooling is used

packages/agent-base/src/index.ts

+77-8
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as net from 'net';
22
import * as tls from 'tls';
33
import * as http from 'http';
4+
import { Agent as HttpsAgent } from 'https';
45
import type { Duplex } from 'stream';
56

67
export * from './helpers';
@@ -77,6 +78,65 @@ export abstract class Agent extends http.Agent {
7778
);
7879
}
7980

81+
// In order to support async signatures in `connect()` and Node's native
82+
// connection pooling in `http.Agent`, the array of sockets for each origin
83+
// has to be updated synchronously. This is so the length of the array is
84+
// accurate when `addRequest()` is next called. We achieve this by creating a
85+
// fake socket and adding it to `sockets[origin]` and incrementing
86+
// `totalSocketCount`.
87+
private incrementSockets(name: string) {
88+
// If `maxSockets` and `maxTotalSockets` are both Infinity then there is no
89+
// need to create a fake socket because Node.js native connection pooling
90+
// will never be invoked.
91+
if (this.maxSockets === Infinity && this.maxTotalSockets === Infinity) {
92+
return null;
93+
}
94+
// All instances of `sockets` are expected TypeScript errors. The
95+
// alternative is to add it as a private property of this class but that
96+
// will break TypeScript subclassing.
97+
if (!this.sockets[name]) {
98+
// @ts-expect-error `sockets` is readonly in `@types/node`
99+
this.sockets[name] = [];
100+
}
101+
const fakeSocket = new net.Socket({ writable: false });
102+
(this.sockets[name] as net.Socket[]).push(fakeSocket);
103+
// @ts-expect-error `totalSocketCount` isn't defined in `@types/node`
104+
this.totalSocketCount++;
105+
return fakeSocket;
106+
}
107+
108+
private decrementSockets(name: string, socket: null | net.Socket) {
109+
if (!this.sockets[name] || socket === null) {
110+
return;
111+
}
112+
const sockets = this.sockets[name] as net.Socket[];
113+
const index = sockets.indexOf(socket);
114+
if (index !== -1) {
115+
sockets.splice(index, 1);
116+
// @ts-expect-error `totalSocketCount` isn't defined in `@types/node`
117+
this.totalSocketCount--;
118+
if (sockets.length === 0) {
119+
// @ts-expect-error `sockets` is readonly in `@types/node`
120+
delete this.sockets[name];
121+
}
122+
}
123+
}
124+
125+
// In order to properly update the socket pool, we need to call `getName()` on
126+
// the core `https.Agent` if it is a secureEndpoint.
127+
getName(options: AgentConnectOpts): string {
128+
const secureEndpoint =
129+
typeof options.secureEndpoint === 'boolean'
130+
? options.secureEndpoint
131+
: this.isSecureEndpoint(options);
132+
if (secureEndpoint) {
133+
// @ts-expect-error `getName()` isn't defined in `@types/node`
134+
return HttpsAgent.prototype.getName.call(this, options);
135+
}
136+
// @ts-expect-error `getName()` isn't defined in `@types/node`
137+
return super.getName(options);
138+
}
139+
80140
createSocket(
81141
req: http.ClientRequest,
82142
options: AgentConnectOpts,
@@ -86,17 +146,26 @@ export abstract class Agent extends http.Agent {
86146
...options,
87147
secureEndpoint: this.isSecureEndpoint(options),
88148
};
149+
const name = this.getName(connectOpts);
150+
const fakeSocket = this.incrementSockets(name);
89151
Promise.resolve()
90152
.then(() => this.connect(req, connectOpts))
91-
.then((socket) => {
92-
if (socket instanceof http.Agent) {
93-
// @ts-expect-error `addRequest()` isn't defined in `@types/node`
94-
return socket.addRequest(req, connectOpts);
153+
.then(
154+
(socket) => {
155+
this.decrementSockets(name, fakeSocket);
156+
if (socket instanceof http.Agent) {
157+
// @ts-expect-error `addRequest()` isn't defined in `@types/node`
158+
return socket.addRequest(req, connectOpts);
159+
}
160+
this[INTERNAL].currentSocket = socket;
161+
// @ts-expect-error `createSocket()` isn't defined in `@types/node`
162+
super.createSocket(req, options, cb);
163+
},
164+
(err) => {
165+
this.decrementSockets(name, fakeSocket);
166+
cb(err);
95167
}
96-
this[INTERNAL].currentSocket = socket;
97-
// @ts-expect-error `createSocket()` isn't defined in `@types/node`
98-
super.createSocket(req, options, cb);
99-
}, cb);
168+
);
100169
}
101170

102171
createConnection(): Duplex {

packages/agent-base/test/test.ts

+117
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ describe('Agent (TypeScript)', () => {
7979
) {
8080
gotCallback = true;
8181
assert(opts.secureEndpoint === false);
82+
assert.equal(this.getName(opts), `127.0.0.1:${port}:`);
8283
return net.connect(opts);
8384
}
8485
}
@@ -308,6 +309,60 @@ describe('Agent (TypeScript)', () => {
308309
server2.close();
309310
}
310311
});
312+
313+
it('should support `keepAlive: true` with `maxSockets`', async () => {
314+
let reqCount = 0;
315+
let connectCount = 0;
316+
317+
class MyAgent extends Agent {
318+
async connect(
319+
_req: http.ClientRequest,
320+
opts: AgentConnectOpts
321+
) {
322+
connectCount++;
323+
assert(opts.secureEndpoint === false);
324+
await sleep(10);
325+
return net.connect(opts);
326+
}
327+
}
328+
const agent = new MyAgent({ keepAlive: true, maxSockets: 1 });
329+
330+
const server = http.createServer(async (req, res) => {
331+
expect(req.headers.connection).toEqual('keep-alive');
332+
reqCount++;
333+
await sleep(10);
334+
res.end();
335+
});
336+
const addr = await listen(server);
337+
338+
try {
339+
const resPromise = req(new URL('/foo', addr), { agent });
340+
const res2Promise = req(new URL('/another', addr), {
341+
agent,
342+
});
343+
344+
const res = await resPromise;
345+
expect(reqCount).toEqual(1);
346+
expect(connectCount).toEqual(1);
347+
expect(res.headers.connection).toEqual('keep-alive');
348+
349+
res.resume();
350+
const s1 = res.socket;
351+
await once(s1, 'free');
352+
353+
const res2 = await res2Promise;
354+
expect(reqCount).toEqual(2);
355+
expect(connectCount).toEqual(1);
356+
expect(res2.headers.connection).toEqual('keep-alive');
357+
assert(res2.socket === s1);
358+
359+
res2.resume();
360+
await once(res2.socket, 'free');
361+
} finally {
362+
agent.destroy();
363+
server.close();
364+
}
365+
});
311366
});
312367

313368
describe('"https" module', () => {
@@ -322,6 +377,10 @@ describe('Agent (TypeScript)', () => {
322377
): net.Socket {
323378
gotCallback = true;
324379
assert(opts.secureEndpoint === true);
380+
assert.equal(
381+
this.getName(opts),
382+
`127.0.0.1:${port}::::::::false:::::::::::::`
383+
);
325384
return tls.connect(opts);
326385
}
327386
}
@@ -509,5 +568,63 @@ describe('Agent (TypeScript)', () => {
509568
server.close();
510569
}
511570
});
571+
572+
it('should support `keepAlive: true` with `maxSockets`', async () => {
573+
let reqCount = 0;
574+
let connectCount = 0;
575+
576+
class MyAgent extends Agent {
577+
async connect(
578+
_req: http.ClientRequest,
579+
opts: AgentConnectOpts
580+
) {
581+
connectCount++;
582+
assert(opts.secureEndpoint === true);
583+
await sleep(10);
584+
return tls.connect(opts);
585+
}
586+
}
587+
const agent = new MyAgent({ keepAlive: true, maxSockets: 1 });
588+
589+
const server = https.createServer(sslOptions, async (req, res) => {
590+
expect(req.headers.connection).toEqual('keep-alive');
591+
reqCount++;
592+
await sleep(10);
593+
res.end();
594+
});
595+
const addr = await listen(server);
596+
597+
try {
598+
const resPromise = req(new URL('/foo', addr), {
599+
agent,
600+
rejectUnauthorized: false,
601+
});
602+
const res2Promise = req(new URL('/another', addr), {
603+
agent,
604+
rejectUnauthorized: false,
605+
});
606+
607+
const res = await resPromise;
608+
expect(reqCount).toEqual(1);
609+
expect(connectCount).toEqual(1);
610+
expect(res.headers.connection).toEqual('keep-alive');
611+
612+
res.resume();
613+
const s1 = res.socket;
614+
await once(s1, 'free');
615+
616+
const res2 = await res2Promise;
617+
expect(reqCount).toEqual(2);
618+
expect(connectCount).toEqual(1);
619+
expect(res2.headers.connection).toEqual('keep-alive');
620+
assert(res2.socket === s1);
621+
622+
res2.resume();
623+
await once(res2.socket, 'free');
624+
} finally {
625+
agent.destroy();
626+
server.close();
627+
}
628+
});
512629
});
513630
});

0 commit comments

Comments
 (0)