Skip to content

Commit 86a6046

Browse files
feat(node-http-handler): implement connections pool and manager interfaces (#4508)
1 parent 25aec20 commit 86a6046

10 files changed

+393
-119
lines changed

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

+27-6
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,35 @@ import { Agent as hsAgent, request as hsRequest, RequestOptions } from "https";
66

77
import { NODEJS_TIMEOUT_ERROR_CODES } from "./constants";
88
import { getTransformedHeaders } from "./get-transformed-headers";
9-
import { setConnectionTimeout } from "./set-connection-timeout";
10-
import { setSocketTimeout } from "./set-socket-timeout";
119
import { writeRequestBody } from "./write-request-body";
1210

1311
/**
1412
* Represents the http options that can be passed to a node http client.
1513
*/
1614
export interface NodeHttpHandlerOptions {
1715
/**
16+
* @deprecated Use {@link requestTimeout}
17+
*
18+
* Note:{@link NodeHttpHandler} will resolve request timeout via nullish coalescing the following fields:
19+
* {@link requestTimeout} ?? {@link connectionTimeout} ?? {@link socketTimeout} ?? {@link DEFAULT_REQUEST_TIMEOUT}
20+
*
1821
* The maximum time in milliseconds that the connection phase of a request
1922
* may take before the connection attempt is abandoned.
2023
*/
2124
connectionTimeout?: number;
2225

2326
/**
27+
* The maximum time in milliseconds that the connection phase of a request
28+
* may take before the connection attempt is abandoned.
29+
*/
30+
requestTimeout?: number;
31+
32+
/**
33+
* @deprecated Use {@link requestTimeout}
34+
*
35+
* Note:{@link NodeHttpHandler} will resolve request timeout via nullish coalescing the following fields:
36+
* {@link requestTimeout} ?? {@link connectionTimeout} ?? {@link socketTimeout} ?? {@link DEFAULT_REQUEST_TIMEOUT}
37+
*
2438
* The maximum time in milliseconds that a socket may remain idle before it
2539
* is closed.
2640
*/
@@ -31,12 +45,15 @@ export interface NodeHttpHandlerOptions {
3145
}
3246

3347
interface ResolvedNodeHttpHandlerConfig {
48+
requestTimeout: number;
3449
connectionTimeout?: number;
3550
socketTimeout?: number;
3651
httpAgent: hAgent;
3752
httpsAgent: hsAgent;
3853
}
3954

55+
export const DEFAULT_REQUEST_TIMEOUT = 0;
56+
4057
export class NodeHttpHandler implements HttpHandler {
4158
private config?: ResolvedNodeHttpHandlerConfig;
4259
private readonly configProvider: Promise<ResolvedNodeHttpHandlerConfig>;
@@ -59,12 +76,14 @@ export class NodeHttpHandler implements HttpHandler {
5976
}
6077

6178
private resolveDefaultConfig(options?: NodeHttpHandlerOptions | void): ResolvedNodeHttpHandlerConfig {
62-
const { connectionTimeout, socketTimeout, httpAgent, httpsAgent } = options || {};
79+
const { requestTimeout, connectionTimeout, socketTimeout, httpAgent, httpsAgent } = options || {};
6380
const keepAlive = true;
6481
const maxSockets = 50;
82+
6583
return {
6684
connectionTimeout,
6785
socketTimeout,
86+
requestTimeout: requestTimeout ?? connectionTimeout ?? socketTimeout ?? DEFAULT_REQUEST_TIMEOUT,
6887
httpAgent: httpAgent || new hAgent({ keepAlive, maxSockets }),
6988
httpsAgent: httpsAgent || new hsAgent({ keepAlive, maxSockets }),
7089
};
@@ -123,9 +142,11 @@ export class NodeHttpHandler implements HttpHandler {
123142
}
124143
});
125144

126-
// wire-up any timeout logic
127-
setConnectionTimeout(req, reject, this.config.connectionTimeout);
128-
setSocketTimeout(req, reject, this.config.socketTimeout);
145+
const timeout: number = this.config?.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT;
146+
req.setTimeout(timeout, () => {
147+
req.destroy();
148+
reject(Object.assign(new Error(`Connection timed out after ${timeout} ms`), { name: "TimeoutError" }));
149+
});
129150

130151
// wire-up abort logic
131152
if (abortSignal) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { RequestContext } from "@aws-sdk/types";
2+
import { ConnectConfiguration } from "@aws-sdk/types/src/connection/config";
3+
import { ConnectionManager, ConnectionManagerConfiguration } from "@aws-sdk/types/src/connection/manager";
4+
import http2, { ClientHttp2Session } from "http2";
5+
6+
import { NodeHttp2ConnectionPool } from "./node-http2-connection-pool";
7+
8+
export class NodeHttp2ConnectionManager implements ConnectionManager<ClientHttp2Session> {
9+
constructor(config: ConnectionManagerConfiguration) {
10+
this.config = config;
11+
12+
if (this.config.maxConcurrency && this.config.maxConcurrency <= 0) {
13+
throw new RangeError("maxConcurrency must be greater than zero.");
14+
}
15+
}
16+
17+
private config: ConnectionManagerConfiguration;
18+
19+
private readonly sessionCache: Map<string, NodeHttp2ConnectionPool> = new Map<string, NodeHttp2ConnectionPool>();
20+
21+
public lease(requestContext: RequestContext, connectionConfiguration: ConnectConfiguration): ClientHttp2Session {
22+
const url = this.getUrlString(requestContext);
23+
24+
const existingPool = this.sessionCache.get(url);
25+
26+
if (existingPool) {
27+
const existingSession = existingPool.poll();
28+
if (existingSession && !this.config.disableConcurrency) {
29+
return existingSession;
30+
}
31+
}
32+
33+
const session = http2.connect(url);
34+
35+
if (this.config.maxConcurrency) {
36+
session.settings({ maxConcurrentStreams: this.config.maxConcurrency }, (err) => {
37+
if (err) {
38+
throw new Error(
39+
"Fail to set maxConcurrentStreams to " +
40+
this.config.maxConcurrency +
41+
"when creating new session for " +
42+
requestContext.destination.toString()
43+
);
44+
}
45+
});
46+
}
47+
48+
// AWS SDK does not expect server push streams, don't keep node alive without a request.
49+
session.unref();
50+
51+
const destroySessionCb = () => {
52+
session.destroy();
53+
this.deleteSession(url, session);
54+
};
55+
session.on("goaway", destroySessionCb);
56+
session.on("error", destroySessionCb);
57+
session.on("frameError", destroySessionCb);
58+
session.on("close", () => this.deleteSession(url, session));
59+
60+
if (connectionConfiguration.requestTimeout) {
61+
session.setTimeout(connectionConfiguration.requestTimeout, destroySessionCb);
62+
}
63+
64+
const connectionPool = this.sessionCache.get(url) || new NodeHttp2ConnectionPool();
65+
66+
connectionPool.offerLast(session);
67+
68+
this.sessionCache.set(url, connectionPool);
69+
70+
return session;
71+
}
72+
73+
/**
74+
* Delete a session from the connection pool.
75+
* @param authority The authority of the session to delete.
76+
* @param session The session to delete.
77+
*/
78+
public deleteSession(authority: string, session: ClientHttp2Session): void {
79+
const existingConnectionPool = this.sessionCache.get(authority);
80+
81+
if (!existingConnectionPool) {
82+
return;
83+
}
84+
85+
if (!existingConnectionPool.contains(session)) {
86+
return;
87+
}
88+
89+
existingConnectionPool.remove(session);
90+
91+
this.sessionCache.set(authority, existingConnectionPool);
92+
}
93+
94+
public release(requestContext: RequestContext, session: ClientHttp2Session): void {
95+
const cacheKey = this.getUrlString(requestContext);
96+
this.sessionCache.get(cacheKey)?.offerLast(session);
97+
}
98+
99+
public destroy(): void {
100+
for (const [key, connectionPool] of this.sessionCache) {
101+
for (const session of connectionPool) {
102+
if (!session.destroyed) {
103+
session.destroy();
104+
}
105+
connectionPool.remove(session);
106+
}
107+
this.sessionCache.delete(key);
108+
}
109+
}
110+
111+
public setMaxConcurrentStreams(maxConcurrentStreams: number) {
112+
if (this.config.maxConcurrency && this.config.maxConcurrency <= 0) {
113+
throw new RangeError("maxConcurrentStreams must be greater than zero.");
114+
}
115+
this.config.maxConcurrency = maxConcurrentStreams;
116+
}
117+
118+
public setDisableConcurrentStreams(disableConcurrentStreams: boolean) {
119+
this.config.disableConcurrency = disableConcurrentStreams;
120+
}
121+
122+
private getUrlString(request: RequestContext): string {
123+
return request.destination.toString();
124+
}
125+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { ConnectionPool } from "@aws-sdk/types/src/connection/pool";
2+
import { ClientHttp2Session } from "http2";
3+
4+
export class NodeHttp2ConnectionPool implements ConnectionPool<ClientHttp2Session> {
5+
private sessions: ClientHttp2Session[] = [];
6+
7+
constructor(sessions?: ClientHttp2Session[]) {
8+
this.sessions = sessions ?? [];
9+
}
10+
11+
public poll(): ClientHttp2Session | void {
12+
if (this.sessions.length > 0) {
13+
return this.sessions.shift();
14+
}
15+
}
16+
17+
public offerLast(session: ClientHttp2Session): void {
18+
this.sessions.push(session);
19+
}
20+
21+
public contains(session: ClientHttp2Session): boolean {
22+
return this.sessions.includes(session);
23+
}
24+
25+
public remove(session: ClientHttp2Session): void {
26+
this.sessions = this.sessions.filter((s) => s !== session);
27+
}
28+
29+
public [Symbol.iterator]() {
30+
return this.sessions[Symbol.iterator]();
31+
}
32+
33+
public destroy(connection: ClientHttp2Session): void {
34+
for (const session of this.sessions) {
35+
if (session === connection) {
36+
if (!session.destroyed) {
37+
session.destroy();
38+
}
39+
}
40+
}
41+
}
42+
}

0 commit comments

Comments
 (0)