Skip to content

Commit 6810dba

Browse files
committed
feat: add checked socket exhaustion warning when throughput is slow
1 parent 74d1aa5 commit 6810dba

File tree

4 files changed

+110
-1
lines changed

4 files changed

+110
-1
lines changed

.changeset/lazy-elephants-hope.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@smithy/node-http-handler": minor
3+
"@smithy/types": minor
4+
---
5+
6+
add socket exhaustion checked warning to node-http-handler

packages/node-http-handler/src/node-http-handler.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,4 +704,37 @@ describe("NodeHttpHandler", () => {
704704
expect(nodeHttpHandler.httpHandlerConfigs()).toEqual({});
705705
});
706706
});
707+
708+
describe("checkSocketUsage", () => {
709+
beforeEach(() => {
710+
jest.spyOn(console, "warn").mockImplementation(jest.fn());
711+
});
712+
713+
afterEach(() => {
714+
jest.resetAllMocks();
715+
});
716+
717+
it("warns when socket exhaustion is detected", async () => {
718+
const lastTimestamp = Date.now() - 30_000;
719+
const warningTimestamp = NodeHttpHandler.checkSocketUsage(
720+
{
721+
maxSockets: 2,
722+
sockets: {
723+
addr: [null, null],
724+
},
725+
requests: {
726+
addr: [null, null, null, null],
727+
},
728+
} as any,
729+
lastTimestamp
730+
);
731+
732+
expect(warningTimestamp).toBeGreaterThan(lastTimestamp);
733+
expect(console.warn).toHaveBeenCalledWith(
734+
"@smithy/node-http-handler:WARN",
735+
"socket usage at capacity=2 and 4 additional requests are enqueued.",
736+
"See https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/node-configuring-maxsockets.html"
737+
);
738+
});
739+
});
707740
});

packages/node-http-handler/src/node-http-handler.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export { NodeHttpHandlerOptions };
1717
interface ResolvedNodeHttpHandlerConfig {
1818
requestTimeout?: number;
1919
connectionTimeout?: number;
20+
socketAcquisitionWarningTimeout?: number;
2021
httpAgent: hAgent;
2122
httpsAgent: hsAgent;
2223
}
@@ -26,6 +27,8 @@ export const DEFAULT_REQUEST_TIMEOUT = 0;
2627
export class NodeHttpHandler implements HttpHandler<NodeHttpHandlerOptions> {
2728
private config?: ResolvedNodeHttpHandlerConfig;
2829
private configProvider: Promise<ResolvedNodeHttpHandlerConfig>;
30+
private socketWarningTimestamp = 0;
31+
private socketCheckTimeoutId = (null as unknown) as NodeJS.Timeout;
2932

3033
// Node http handler is hard-coded to http/1.1: https://github.com/nodejs/node/blob/ff5664b83b89c55e4ab5d5f60068fb457f1f5872/lib/_http_server.js#L286
3134
public readonly metadata = { handlerProtocol: "http/1.1" };
@@ -45,6 +48,53 @@ export class NodeHttpHandler implements HttpHandler<NodeHttpHandlerOptions> {
4548
return new NodeHttpHandler(instanceOrOptions as NodeHttpHandlerOptions);
4649
}
4750

51+
/**
52+
* @internal
53+
*
54+
* @param agent - http(s) agent in use by the NodeHttpHandler instance.
55+
* @returns timestamp of last emitted warning.
56+
*/
57+
public static checkSocketUsage(agent: hAgent | hsAgent, socketWarningTimestamp: number): number {
58+
// note, maxSockets is per origin.
59+
const { sockets, requests, maxSockets } = agent;
60+
61+
if (typeof maxSockets !== "number" || maxSockets === Infinity) {
62+
return socketWarningTimestamp;
63+
}
64+
65+
const interval = 15_000;
66+
if (Date.now() - interval < socketWarningTimestamp) {
67+
return socketWarningTimestamp;
68+
}
69+
70+
let socketsInUse = 0;
71+
let requestsEnqueued = 0;
72+
73+
if (sockets) {
74+
for (const key in sockets) {
75+
socketsInUse = Math.max(socketsInUse, sockets[key]?.length ?? 0);
76+
}
77+
}
78+
79+
if (requests) {
80+
for (const key in requests) {
81+
requestsEnqueued = Math.max(requestsEnqueued, requests[key]?.length ?? 0);
82+
}
83+
}
84+
85+
// This threshold is somewhat arbitrary.
86+
// A few enqueued requests is not worth warning about.
87+
if (socketsInUse >= maxSockets && requestsEnqueued >= 2 * maxSockets) {
88+
console.warn(
89+
"@smithy/node-http-handler:WARN",
90+
`socket usage at capacity=${socketsInUse} and ${requestsEnqueued} additional requests are enqueued.`,
91+
"See https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/node-configuring-maxsockets.html"
92+
);
93+
return Date.now();
94+
}
95+
return socketWarningTimestamp;
96+
}
97+
4898
constructor(options?: NodeHttpHandlerOptions | Provider<NodeHttpHandlerOptions | void>) {
4999
this.configProvider = new Promise((resolve, reject) => {
50100
if (typeof options === "function") {
@@ -81,10 +131,13 @@ export class NodeHttpHandler implements HttpHandler<NodeHttpHandlerOptions> {
81131
if (!this.config) {
82132
this.config = await this.configProvider;
83133
}
134+
84135
return new Promise((_resolve, _reject) => {
85136
let writeRequestBodyPromise: Promise<void> | undefined = undefined;
86137
const resolve = async (arg: { response: HttpResponse }) => {
87138
await writeRequestBodyPromise;
139+
// if requests are still resolving, cancel the socket usage check.
140+
clearTimeout(this.socketCheckTimeoutId);
88141
_resolve(arg);
89142
};
90143
const reject = async (arg: unknown) => {
@@ -106,6 +159,14 @@ export class NodeHttpHandler implements HttpHandler<NodeHttpHandlerOptions> {
106159

107160
// determine which http(s) client to use
108161
const isSSL = request.protocol === "https:";
162+
const agent = isSSL ? this.config.httpsAgent : this.config.httpAgent;
163+
164+
// If the request is taking a long time, check socket usage and potentially warn.
165+
// This warning will be cancelled if the request resolves.
166+
this.socketCheckTimeoutId = setTimeout(() => {
167+
this.socketWarningTimestamp = NodeHttpHandler.checkSocketUsage(agent, this.socketWarningTimestamp);
168+
}, this.config.socketAcquisitionWarningTimeout ?? (this.config.requestTimeout ?? 2000) + (this.config.connectionTimeout ?? 1000));
169+
109170
const queryString = buildQueryString(request.query || {});
110171
let auth = undefined;
111172
if (request.username != null || request.password != null) {
@@ -126,7 +187,7 @@ export class NodeHttpHandler implements HttpHandler<NodeHttpHandlerOptions> {
126187
method: request.method,
127188
path,
128189
port: request.port,
129-
agent: isSSL ? this.config.httpsAgent : this.config.httpAgent,
190+
agent,
130191
auth,
131192
};
132193

packages/types/src/http/httpHandlerInitialization.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ export interface NodeHttpHandlerOptions {
3434
*/
3535
requestTimeout?: number;
3636

37+
/**
38+
* Delay before the NodeHttpHandler checks for socket exhaustion,
39+
* and emits a warning if the active sockets and enqueued request count is greater than
40+
* 2x the maxSockets count.
41+
*
42+
* Defaults to connectionTimeout + requestTimeout or 3000ms if those are not set.
43+
*/
44+
socketAcquisitionWarningTimeout?: number;
45+
3746
/**
3847
* @deprecated Use {@link requestTimeout}
3948
*

0 commit comments

Comments
 (0)