blob: 3396db4618f3dd279bb8d549ba8411b6b9ec5e6f [file]
// Copyright 2021-2023 Buf Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as http2 from "http2";
import { Code, DubboError } from "@apachedubbo/dubbo";
import { dubboErrorFromNodeReason } from "./node-error.js";
export interface Http2SessionOptions {
/**
* The interval to send PING frames to keep a connection alive. The interval
* is reset whenever a stream receives data. If a PING frame is not responded
* to within pingTimeoutMs, the connection and all open streams close.
*
* By default, no PING frames are sent. If a value is provided, PING frames
* are sent only for connections that have open streams, unless
* pingIdleConnections is enabled.
*
* Sensible values can be between 10 seconds and 2 hours, depending on your
* infrastructure.
*
* This option is equivalent to GRPC_ARG_KEEPALIVE_TIME_MS in gRPC Core.
*/
pingIntervalMs?: number;
/**
* Enable PING frames for connections that are have no open streams.
* This option is only effective if a value for pingIntervalMs is provided.
*
* Note that it may not be necessary to enable this option. If a request is
* made on a connection that has not been used for longer than pingIntervalMs,
* a PING frame is sent to verify that the connection is still alive, and a
* new connection is opened transparently if necessary.
*
* Defaults to false.
*
* This option is equivalent to GRPC_ARG_KEEPALIVE_PERMIT_WITHOUT_CALLS in
* gRPC Core.
*/
pingIdleConnection?: boolean;
/**
* Timeout for PING frames. If a PING is not answered within this time, the
* connection is considered dead.
*
* Defaults to 15 seconds. This option is only used if a value for
* pingIntervalMs is provided.
*
* This option is equivalent to GRPC_ARG_KEEPALIVE_TIME_MS in gRPC Core.
*/
pingTimeoutMs?: number;
/**
* Automatically close a connection if the time since the last request stream
* exceeds this value.
*
* Defaults to 15 minutes.
*
* This option is equivalent to GRPC_ARG_CLIENT_IDLE_TIMEOUT_MS of gRPC core.
*/
idleConnectionTimeoutMs?: number;
}
/**
* Manage a single HTTP/2 connection and keep it alive with PING frames.
*
* The logic is based on "Basic Keepalive" described in
* https://github.com/grpc/proposal/blob/0ba0c1905050525f9b0aee46f3f23c8e1e515489/A8-client-side-keepalive.md#basic-keepalive
* as well as the client channel arguments described in
* https://github.com/grpc/grpc/blob/8e137e524a1b1da7bbf4603662876d5719563b57/doc/keepalive.md
*
*/
export class Http2SessionManager {
/**
* The host this session manager connect to.
*/
authority: string;
/**
* The current state of the connection:
*
* - "closed"
* The connection is closed, or no connection has been opened yet.
* - connecting
* Currently establishing a connection.
*
* - "open"
* A connection is open and has open streams. PING frames are sent every
* pingIntervalMs, unless a stream received data.
* If a PING frame is not responded to within pingTimeoutMs, the connection
* and all open streams close.
*
* - "idle"
* A connection is open, but it does not have any open streams.
* If pingIdleConnection is enabled, PING frames are used to keep the
* connection alive, similar to an "open" connection.
* If a connection is idle for longer than idleConnectionTimeoutMs, it closes.
* If a request is made on an idle connection that has not been used for
* longer than pingIntervalMs, the connection is verified.
*
* - "verifying"
* Verifying a connection after a long period of inactivity before issuing a
* request. A PING frame is sent, and if it times out within pingTimeoutMs, a
* new connection is opened.
*
* - "error"
* The connection is closed because of a transient error. A connection
* may have failed to reach the host, or the connection may have died,
* or it may have been aborted.
*/
state(): "closed" | "connecting" | "open" | "idle" | "verifying" | "error" {
if (this.s.t == "ready") {
return this.s.streamCount() > 0 ? "open" : "idle";
}
return this.s.t;
}
/**
* Returns the error object if the connection is in the "error" state,
* `undefined` otherwise.
*/
error(): unknown {
if (this.s.t == "error") {
return this.s.reason;
}
return undefined;
}
private s:
| StateClosed
| StateError
| StateConnecting
| StateVerifying
| StateReady = closed();
private readonly http2SessionOptions:
| http2.ClientSessionOptions
| http2.SecureClientSessionOptions
| undefined;
private readonly options: Required<Http2SessionOptions>;
public constructor(
authority: URL | string,
pingOptions?: Http2SessionOptions,
http2SessionOptions?:
| http2.ClientSessionOptions
| http2.SecureClientSessionOptions
) {
this.authority = new URL(authority).origin;
this.http2SessionOptions = http2SessionOptions;
this.options = {
pingIntervalMs: pingOptions?.pingIntervalMs ?? Number.POSITIVE_INFINITY,
pingTimeoutMs: pingOptions?.pingTimeoutMs ?? 1000 * 15,
pingIdleConnection: pingOptions?.pingIdleConnection ?? false,
idleConnectionTimeoutMs:
pingOptions?.idleConnectionTimeoutMs ?? 1000 * 60 * 15,
};
}
/**
* Open a connection if none exists, verify an existing connection if
* necessary.
*/
async connect(): Promise<"open" | "idle" | "error"> {
try {
const ready = await this.gotoReady();
return ready.streamCount() > 0 ? "open" : "idle";
} catch (e) {
return "error";
}
}
/**
* Issue a request.
*
* This method automatically opens a connection if none exists, and verifies
* an existing connection if necessary. It calls http2.ClientHttp2Session.request(),
* and keeps track of all open http2.ClientHttp2Stream.
*
* Clients must call notifyResponseByteRead() whenever they successfully read
* data from the http2.ClientHttp2Stream.
*/
async request(
method: string,
path: string,
headers: http2.OutgoingHttpHeaders,
options: Omit<http2.ClientSessionRequestOptions, "signal">
): Promise<http2.ClientHttp2Stream> {
const ready = await this.gotoReady();
const stream = ready.conn.request(
{
...headers,
":method": method,
":path": path,
},
options
);
ready.registerRequest(stream);
return stream;
}
/**
* Notify the manager of a successful read from a http2.ClientHttp2Stream.
*
* Clients must call this function whenever they successfully read data from
* a http2.ClientHttp2Stream obtained from request(). This informs the
* keep-alive logic that the connection is alive, and prevents it from sending
* unnecessary PING frames.
*/
notifyResponseByteRead(stream: http2.ClientHttp2Stream): void {
if (this.s.t == "ready") {
this.s.responseByteRead(stream);
}
}
/**
* If there is an open connection, close it. This also closes any open streams.
*/
abort(reason?: Error): void {
const err = reason ?? new DubboError("connection aborted", Code.Canceled);
this.s.abort?.(err);
this.setState(closedOrError(err));
}
private async gotoReady() {
if (this.s.t == "ready") {
if (this.s.requiresVerify()) {
this.setState(
verify(this.s, this.options, this.authority, this.http2SessionOptions)
);
}
} else if (this.s.t == "closed" || this.s.t == "error") {
this.setState(connect(this.authority, this.http2SessionOptions));
}
while (this.s.t !== "ready") {
if (this.s.t === "error") {
throw this.s.reason;
}
if (this.s.t === "connecting") {
await this.s.conn;
}
if (this.s.t === "verifying") {
await this.s.verified;
}
}
return this.s;
}
private setState(
this: Http2SessionManager,
state:
| StateClosed
| StateError
| StateConnecting
| StateVerifying
| StateReady
): void {
this.s.onExitState?.();
switch (state.t) {
case "connecting":
state.conn.then(
(value) => {
this.setState(ready(value, this.options));
},
(reason) => {
this.setState(closedOrError(reason));
}
);
break;
case "verifying":
state.verified.then(
(value) => {
if ("t" in value) {
this.setState(value);
} else {
this.setState(ready(value, this.options));
}
},
(reason) => {
this.setState(closedOrError(reason));
}
);
break;
case "ready":
state.onClose = () => this.setState(closed());
state.onError = (err) => this.setState(closedOrError(err));
break;
case "closed":
break;
case "error":
break;
}
this.s = state;
}
}
interface StateCommon {
/**
* A unique string that serves as a discriminator for each state type.
*/
readonly t: string;
/**
* Abort this state, cancelling any work, and terminating any connection.
*/
abort?: (reason?: Error) => void;
/**
* Called when the manager is leaving this state.
*/
onExitState?: () => void;
}
/**
* The connection is closed, or no connection has been opened yet.
*/
interface StateClosed extends StateCommon {
readonly t: "closed";
}
function closed(): StateClosed {
return {
t: "closed",
};
}
/**
* The connection is closed because of a transient error.
* A connection may have failed to reach the host, or the connection may have
* died, or it may have been aborted.
*/
interface StateError extends StateCommon {
readonly t: "error";
/**
* The error.
*/
readonly reason: unknown;
}
function error(reason: unknown): StateError {
return {
t: "error",
reason,
};
}
function closedOrError(reason: unknown) {
const isCancel =
reason instanceof DubboError &&
DubboError.from(reason).code == Code.Canceled;
return isCancel ? closed() : error(reason);
}
/**
* The manager is currently establishing a connection.
*/
interface StateConnecting extends StateCommon {
readonly t: "connecting";
/**
* A promise for the new connection that resolves if the connection was
* established, but rejects if the connection failed or the state was aborted.
*/
readonly conn: Promise<http2.ClientHttp2Session>;
}
function connect(
authority: string,
http2SessionOptions:
| http2.ClientSessionOptions
| http2.SecureClientSessionOptions
| undefined
): StateConnecting {
let resolve: ((value: http2.ClientHttp2Session) => void) | undefined;
let reject: ((reason: unknown) => void) | undefined;
const conn = new Promise<http2.ClientHttp2Session>((res, rej) => {
resolve = res;
reject = rej;
});
const newConn = http2.connect(authority, http2SessionOptions);
newConn.on("connect", onConnect);
newConn.on("error", onError);
function onConnect() {
resolve?.(newConn);
cleanup();
}
function onError(err: unknown) {
reject?.(dubboErrorFromNodeReason(err));
cleanup();
}
function cleanup() {
newConn.off("connect", onConnect);
newConn.off("error", onError);
}
return {
t: "connecting",
conn,
abort(reason) {
if (!newConn.destroyed) {
newConn.destroy(undefined, http2.constants.NGHTTP2_CANCEL);
}
// According to the documentation, destroy() should immediately terminate
// the session and the socket, but we still receive a "connect" event.
// We must not resolve a broken connection, so we reject it manually here.
reject?.(reason);
},
onExitState() {
cleanup();
},
} satisfies StateConnecting;
}
interface StateVerifying extends StateCommon {
readonly t: "verifying";
/**
* The existing connection (StateReady) if it has been successfully verified
* with a PING frame. A new connection otherwise.
*/
readonly verified: Promise<StateReady | StateConnecting>;
}
export function verify(
stateReady: StateReady,
options: Required<Http2SessionOptions>,
authority: string,
http2SessionOptions:
| http2.ClientSessionOptions
| http2.SecureClientSessionOptions
| undefined
): StateVerifying {
const verified = stateReady.ping().then((success) => {
if (success) {
return stateReady;
}
// ping() has destroyed the old connection
return connect(authority, http2SessionOptions);
});
return {
t: "verifying",
verified,
abort(reason) {
stateReady.abort?.(reason);
},
};
}
interface StateReady extends StateCommon {
readonly t: "ready";
/**
* The open connection that is ready to use, but might require verification.
*/
readonly conn: http2.ClientHttp2Session;
/**
* Returns the number of open streams.
*/
streamCount(): number;
/**
* Returns true if the connection should be verified before use, because it
* has not received a PING response or response bytes for longer than
* pingIntervalMs.
*/
requiresVerify(): boolean;
/**
* Register a stream, so that we can keep track of open streams, and keep the
* connection alive with PING frames while streams are open.
*/
registerRequest(stream: http2.ClientHttp2Stream): void;
/**
* Notify the keep-alive logic about received response bytes. A received byte
* is proof that the connection is alive, resets the interval for PING frames.
*/
responseByteRead(stream: http2.ClientHttp2Stream): void;
/**
* Send a PING frame, resolve to true if it is responded to in time, resolve
* to false otherwise (and closes the connection).
*/
ping(): Promise<boolean>;
/**
* Called when the connection closes without error.
*/
onClose: (() => void) | undefined;
/**
* Called when the connection closes with an error.
*/
onError: ((err: Error) => void) | undefined;
}
function ready(
conn: http2.ClientHttp2Session,
options: Required<Http2SessionOptions>
): StateReady {
// the last time we were sure that the connection is alive, via a PING
// response, or via received response bytes
let lastAliveAt = Date.now();
// how many streams are currently open on this session
let streamCount = 0;
// timer for the keep-alive interval
let pingIntervalId: ReturnType<typeof setTimeout> | undefined;
// timer for waiting for a PING response
let pingTimeoutId: ReturnType<typeof setTimeout> | undefined;
// keep track of GOAWAY with ENHANCE_YOUR_CALM and with debug data too_many_pings
let receivedGoAwayEnhanceYourCalmTooManyPings = false;
// timer for closing connections without open streams, must be initialized
let idleTimeoutId: ReturnType<typeof setTimeout> | undefined;
resetIdleTimeout();
const state: StateReady = {
t: "ready",
conn,
streamCount() {
return streamCount;
},
requiresVerify(): boolean {
const elapsedMs = Date.now() - lastAliveAt;
return elapsedMs > options.pingIntervalMs;
},
onClose: undefined,
onError: undefined,
registerRequest(stream: http2.ClientHttp2Stream): void {
streamCount++;
if (streamCount == 1) {
resetPingInterval(); // reset to ping with the appropriate interval for "open"
stopIdleTimeout();
}
stream.once("response", () => {
lastAliveAt = Date.now();
resetPingInterval();
});
stream.once("close", () => {
streamCount--;
if (streamCount == 0) {
resetPingInterval(); // reset to ping with the appropriate interval for "idle"
resetIdleTimeout();
}
});
},
responseByteRead(stream: http2.ClientHttp2Stream) {
if (stream.session !== conn) {
return;
}
if (conn.closed || conn.destroyed) {
return;
}
if (streamCount <= 0) {
return;
}
lastAliveAt = Date.now();
resetPingInterval();
},
ping() {
return new Promise<boolean>((resolve) => {
commonPing(() => resolve(true));
conn.once("error", () => resolve(false));
});
},
abort(reason) {
if (!conn.destroyed) {
conn.once("error", () => {
// conn.destroy() may raise an error after onExitState() was called
// and our error listeners are removed.
// We attach this one to swallow uncaught exceptions.
});
conn.destroy(reason, http2.constants.NGHTTP2_CANCEL);
}
},
onExitState() {
cleanup();
this.onError = undefined;
this.onClose = undefined;
},
};
// start or restart the ping interval
function resetPingInterval() {
stopPingInterval();
if (streamCount > 0 || options.pingIdleConnection) {
pingIntervalId = safeSetTimeout(onPingInterval, options.pingIntervalMs);
}
}
function stopPingInterval() {
clearTimeout(pingIntervalId);
clearTimeout(pingTimeoutId);
}
function onPingInterval() {
commonPing(resetPingInterval);
}
function commonPing(onSuccess: () => void) {
clearTimeout(pingTimeoutId);
pingTimeoutId = safeSetTimeout(() => {
conn.destroy(
new DubboError("PING timed out", Code.Unavailable),
http2.constants.NGHTTP2_CANCEL
);
}, options.pingTimeoutMs);
conn.ping((err, duration) => {
clearTimeout(pingTimeoutId);
if (err !== null) {
// We will receive an ERR_HTTP2_PING_CANCEL here if we destroy the
// connection with a pending ping.
// We might also see other errors, but they should be picked up by the
// "error" event listener.
return;
}
if (duration > options.pingTimeoutMs) {
// setTimeout is not precise, and HTTP/2 pings take less than 1ms in
// tests.
conn.destroy(
new DubboError("PING timed out", Code.Unavailable),
http2.constants.NGHTTP2_CANCEL
);
return;
}
lastAliveAt = Date.now();
onSuccess();
});
}
function stopIdleTimeout() {
clearTimeout(idleTimeoutId);
}
function resetIdleTimeout() {
idleTimeoutId = safeSetTimeout(
onIdleTimeout,
options.idleConnectionTimeoutMs
);
}
function onIdleTimeout() {
conn.close();
onClose(); // trigger a state change right away so we are not open to races
}
function onGoaway(
errorCode: number,
lastStreamID: number,
opaqueData: Buffer
) {
const tooManyPingsAscii = Buffer.from("too_many_pings", "ascii");
if (
errorCode === http2.constants.NGHTTP2_ENHANCE_YOUR_CALM &&
opaqueData.equals(tooManyPingsAscii)
) {
// double pingIntervalMs, following the last paragraph of https://github.com/grpc/proposal/blob/0ba0c1905050525f9b0aee46f3f23c8e1e515489/A8-client-side-keepalive.md#basic-keepalive
options.pingIntervalMs = options.pingIntervalMs * 2;
receivedGoAwayEnhanceYourCalmTooManyPings = true;
}
}
function onClose() {
cleanup();
state.onClose?.();
}
function onError(err: Error) {
cleanup();
if (receivedGoAwayEnhanceYourCalmTooManyPings) {
// We cannot prevent node from destroying session and streams with its own
// error that does not carry debug data, but at least we can wrap the error
// we surface on the manager.
const ce = new DubboError(
`http/2 connection closed with error code ENHANCE_YOUR_CALM (0x${http2.constants.NGHTTP2_ENHANCE_YOUR_CALM.toString(
16
)}), too_many_pings, doubled the interval`,
Code.ResourceExhausted
);
state.onError?.(ce);
} else {
state.onError?.(dubboErrorFromNodeReason(err));
}
}
function cleanup() {
stopPingInterval();
stopIdleTimeout();
conn.off("error", onError);
conn.off("close", onClose);
conn.off("goaway", onGoaway);
}
conn.on("error", onError);
conn.on("close", onClose);
conn.on("goaway", onGoaway);
return state;
}
/**
* setTimeout(), but simply ignores values larger than the maximum supported
* value (signed 32-bit integer) instead of calling the callback right away.
*/
function safeSetTimeout(
callback: () => void,
ms: number
): ReturnType<typeof setTimeout> | undefined {
if (ms > 0x7fffffff) {
return;
}
return setTimeout(callback, ms);
}