Skip to content

Commit 1e23f96

Browse files
authored
feat: add checked socket exhaustion warning when throughput is slow (#1164)
* feat: add checked socket exhaustion warning when throughput is slow * test: update socket check and unit test * feat: use promise-local timeout id * feat: add reference to socketAcquisitionWarningTimeout in warning msg
1 parent 74d1aa5 commit 1e23f96

File tree

4 files changed

+122
-1
lines changed

4 files changed

+122
-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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,4 +704,46 @@ 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: [],
724+
addr2: [null],
725+
addr3: [null, null],
726+
// this is not checked because an earlier addr causes the warning to be emitted.
727+
addr4: Array.from({ length: 400 }),
728+
},
729+
requests: {
730+
addr: Array.from({ length: 0 }),
731+
addr2: Array.from({ length: 3 }),
732+
addr3: Array.from({ length: 4 }),
733+
// this is not checked because an earlier addr causes the warning to be emitted.
734+
addr4: Array.from({ length: 800 }),
735+
},
736+
} as any,
737+
lastTimestamp
738+
);
739+
740+
expect(warningTimestamp).toBeGreaterThan(lastTimestamp);
741+
expect(console.warn).toHaveBeenCalledWith(
742+
"@smithy/node-http-handler:WARN",
743+
"socket usage at capacity=2 and 4 additional requests are enqueued.",
744+
"See https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/node-configuring-maxsockets.html",
745+
"or increase socketAcquisitionWarningTimeout=(millis) in the NodeHttpHandler config."
746+
);
747+
});
748+
});
707749
});

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

Lines changed: 65 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,7 @@ 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;
2931

3032
// Node http handler is hard-coded to http/1.1: https://github.com/nodejs/node/blob/ff5664b83b89c55e4ab5d5f60068fb457f1f5872/lib/_http_server.js#L286
3133
public readonly metadata = { handlerProtocol: "http/1.1" };
@@ -45,6 +47,55 @@ export class NodeHttpHandler implements HttpHandler<NodeHttpHandlerOptions> {
4547
return new NodeHttpHandler(instanceOrOptions as NodeHttpHandlerOptions);
4648
}
4749

50+
/**
51+
* @internal
52+
*
53+
* @param agent - http(s) agent in use by the NodeHttpHandler instance.
54+
* @returns timestamp of last emitted warning.
55+
*/
56+
public static checkSocketUsage(agent: hAgent | hsAgent, socketWarningTimestamp: number): number {
57+
// note, maxSockets is per origin.
58+
const { sockets, requests, maxSockets } = agent;
59+
60+
if (typeof maxSockets !== "number" || maxSockets === Infinity) {
61+
return socketWarningTimestamp;
62+
}
63+
64+
const interval = 15_000;
65+
if (Date.now() - interval < socketWarningTimestamp) {
66+
return socketWarningTimestamp;
67+
}
68+
69+
if (sockets && requests) {
70+
for (const origin in sockets) {
71+
const socketsInUse = sockets[origin]?.length ?? 0;
72+
const requestsEnqueued = requests[origin]?.length ?? 0;
73+
74+
/**
75+
* Running at maximum socket usage can be intentional and normal.
76+
* That is why this warning emits at a delay which can be seen
77+
* at the call site's setTimeout wrapper. The warning will be cancelled
78+
* if the request finishes in a reasonable amount of time regardless
79+
* of socket saturation.
80+
*
81+
* Additionally, when the warning is emitted, there is an interval
82+
* lockout.
83+
*/
84+
if (socketsInUse >= maxSockets && requestsEnqueued >= 2 * maxSockets) {
85+
console.warn(
86+
"@smithy/node-http-handler:WARN",
87+
`socket usage at capacity=${socketsInUse} and ${requestsEnqueued} additional requests are enqueued.`,
88+
"See https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/node-configuring-maxsockets.html",
89+
"or increase socketAcquisitionWarningTimeout=(millis) in the NodeHttpHandler config."
90+
);
91+
return Date.now();
92+
}
93+
}
94+
}
95+
96+
return socketWarningTimestamp;
97+
}
98+
4899
constructor(options?: NodeHttpHandlerOptions | Provider<NodeHttpHandlerOptions | void>) {
49100
this.configProvider = new Promise((resolve, reject) => {
50101
if (typeof options === "function") {
@@ -81,10 +132,15 @@ export class NodeHttpHandler implements HttpHandler<NodeHttpHandlerOptions> {
81132
if (!this.config) {
82133
this.config = await this.configProvider;
83134
}
135+
136+
let socketCheckTimeoutId: NodeJS.Timeout;
137+
84138
return new Promise((_resolve, _reject) => {
85139
let writeRequestBodyPromise: Promise<void> | undefined = undefined;
86140
const resolve = async (arg: { response: HttpResponse }) => {
87141
await writeRequestBodyPromise;
142+
// if requests are still resolving, cancel the socket usage check.
143+
clearTimeout(socketCheckTimeoutId);
88144
_resolve(arg);
89145
};
90146
const reject = async (arg: unknown) => {
@@ -106,6 +162,14 @@ export class NodeHttpHandler implements HttpHandler<NodeHttpHandlerOptions> {
106162

107163
// determine which http(s) client to use
108164
const isSSL = request.protocol === "https:";
165+
const agent = isSSL ? this.config.httpsAgent : this.config.httpAgent;
166+
167+
// If the request is taking a long time, check socket usage and potentially warn.
168+
// This warning will be cancelled if the request resolves.
169+
socketCheckTimeoutId = setTimeout(() => {
170+
this.socketWarningTimestamp = NodeHttpHandler.checkSocketUsage(agent, this.socketWarningTimestamp);
171+
}, this.config.socketAcquisitionWarningTimeout ?? (this.config.requestTimeout ?? 2000) + (this.config.connectionTimeout ?? 1000));
172+
109173
const queryString = buildQueryString(request.query || {});
110174
let auth = undefined;
111175
if (request.username != null || request.password != null) {
@@ -126,7 +190,7 @@ export class NodeHttpHandler implements HttpHandler<NodeHttpHandlerOptions> {
126190
method: request.method,
127191
path,
128192
port: request.port,
129-
agent: isSSL ? this.config.httpsAgent : this.config.httpAgent,
193+
agent,
130194
auth,
131195
};
132196

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)