@microsoft/dev-tunnels-connections
Version:
Tunnels library for Visual Studio tools
247 lines • 13.6 kB
JavaScript
;
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
Object.defineProperty(exports, "__esModule", { value: true });
exports.RelayTunnelConnector = exports.maxReconnectDelayMs = void 0;
const dev_tunnels_ssh_1 = require("@microsoft/dev-tunnels-ssh");
const utils_1 = require("./utils");
const sshHelpers_1 = require("./sshHelpers");
const retryingTunnelConnectionEventArgs_1 = require("./retryingTunnelConnectionEventArgs");
// After the 6th attemt, each next attempt will happen after a delay of 2^7 * 100ms = 12.8s
exports.maxReconnectDelayMs = 13000;
// Delay between the 1st and the 2nd attempts.
const reconnectInitialDelayMs = 1000;
// There is no status code information in web socket errors in browser context.
// Instead, connection will retry anyway with limited retry attempts.
const maxBrowserReconnectAttempts = 5;
/**
* Tunnel connector that connects a tunnel session to a web socket stream in the tunnel Relay service.
*/
class RelayTunnelConnector {
constructor(tunnelSession) {
this.tunnelSession = tunnelSession;
}
get trace() {
return this.tunnelSession.trace;
}
/**
* Connect or reconnect tunnel SSH session.
* @param isReconnect A value indicating if this is a reconnect (true) or regular connect (false).
* @param cancellation Cancellation token.
*/
async connectSession(isReconnect, options, cancellation) {
var _a, _b;
let disconnectReason;
let error;
function throwIfCancellation(e) {
if ((e instanceof dev_tunnels_ssh_1.CancellationError && (cancellation === null || cancellation === void 0 ? void 0 : cancellation.isCancellationRequested)) ||
e instanceof dev_tunnels_ssh_1.ObjectDisposedError) {
error = undefined;
disconnectReason = dev_tunnels_ssh_1.SshDisconnectReason.byApplication;
throw e;
}
}
function throwError(message) {
if (error) {
// Preserve the error object, just replace the message.
error.message = message;
}
else {
error = new Error(message);
}
throw error;
}
let browserReconnectAttempt = 0;
let attemptDelayMs = reconnectInitialDelayMs;
let isTunnelAccessTokenRefreshed = false;
let isDelayNeeded = true;
let errorDescription;
this.tunnelSession.startConnecting();
try {
for (let attempt = 0;; attempt++) {
if (cancellation === null || cancellation === void 0 ? void 0 : cancellation.isCancellationRequested) {
throw new dev_tunnels_ssh_1.CancellationError();
}
if (attempt > 0) {
if (error) {
if (!((_a = options === null || options === void 0 ? void 0 : options.enableRetry) !== null && _a !== void 0 ? _a : true)) {
throw error;
}
const args = new retryingTunnelConnectionEventArgs_1.RetryingTunnelConnectionEventArgs(error, attemptDelayMs);
this.tunnelSession.onRetrying(args);
if (!args.retry) {
// Stop retries.
throw error;
}
if (args.delayMs >= reconnectInitialDelayMs) {
attemptDelayMs = args.delayMs;
}
else {
isDelayNeeded = false;
}
}
const retryTiming = isDelayNeeded
? ` in ${attemptDelayMs < 1000
? `0.${attemptDelayMs / 100}s`
: `${attemptDelayMs / 1000}s`}`
: '';
this.trace(dev_tunnels_ssh_1.TraceLevel.Verbose, 0, `Error connecting to tunnel SSH session, retrying${retryTiming}${errorDescription ? `: ${errorDescription}` : ''}`);
if (isDelayNeeded) {
try {
await (0, utils_1.delay)(attemptDelayMs, cancellation);
}
catch (e) {
throwIfCancellation(e);
throw e;
}
if (attemptDelayMs < exports.maxReconnectDelayMs) {
attemptDelayMs = attemptDelayMs << 1;
}
}
}
isDelayNeeded = true;
let stream = undefined;
errorDescription = undefined;
disconnectReason = dev_tunnels_ssh_1.SshDisconnectReason.connectionLost;
error = undefined;
try {
const streamAndProtocol = await this.tunnelSession.createSessionStream(options, cancellation);
stream = streamAndProtocol.stream;
await this.tunnelSession.configureSession(stream, streamAndProtocol.protocol, isReconnect, cancellation);
stream = undefined;
disconnectReason = undefined;
return;
}
catch (e) {
if (!(e instanceof Error)) {
// Not recoverable if we cannot recognize the error object.
throwError(`Failed to connect to the tunnel service and start tunnel SSH session: ${e}`);
}
throwIfCancellation(e);
error = e;
errorDescription = error.message;
// Browser web socket relay error - retry until max number of attempts is exceeded.
if (e instanceof sshHelpers_1.BrowserWebSocketRelayError) {
if (browserReconnectAttempt++ >= maxBrowserReconnectAttempts) {
throw e;
}
continue;
}
// SSH reconnection error. Disable reconnection and try again without delay.
if (e instanceof dev_tunnels_ssh_1.SshReconnectError) {
disconnectReason = dev_tunnels_ssh_1.SshDisconnectReason.protocolError;
isDelayNeeded = false;
isReconnect = false;
continue;
}
// SSH connection error. Only 'connection lost' is recoverable.
if (e instanceof dev_tunnels_ssh_1.SshConnectionError) {
const reason = e.reason;
if (reason === dev_tunnels_ssh_1.SshDisconnectReason.connectionLost) {
continue;
}
disconnectReason = reason || dev_tunnels_ssh_1.SshDisconnectReason.byApplication;
throwError(`Failed to start tunnel SSH session: ${errorDescription}`);
}
// Web socket connection error
if (e instanceof sshHelpers_1.RelayConnectionError) {
const statusCode = (_b = e.errorContext) === null || _b === void 0 ? void 0 : _b.statusCode;
const statusCodeText = statusCode ? ` (${statusCode})` : '';
switch (errorDescription) {
case 'error.relayClientUnauthorized': {
const notAuthorizedText = 'Not authorized' + statusCodeText;
disconnectReason = dev_tunnels_ssh_1.SshDisconnectReason.authCancelledByUser;
if (isTunnelAccessTokenRefreshed) {
// We've already refreshed the tunnel access token once.
throwError(`${notAuthorizedText}. Refreshed tunnel access token also does not work.`);
}
try {
isTunnelAccessTokenRefreshed = await this.tunnelSession.refreshTunnelAccessToken(cancellation);
}
catch (refreshError) {
throwIfCancellation(refreshError);
throwError(`${notAuthorizedText}. Refreshing tunnel access token failed with error ${(0, utils_1.getErrorMessage)(refreshError)}`);
}
if (!isTunnelAccessTokenRefreshed) {
throwError(`${notAuthorizedText}. Provide a fresh tunnel access token with '${this.tunnelSession.tunnelAccessScope}' scope.`);
}
isDelayNeeded = false;
errorDescription =
'The tunnel access token was no longer valid and had just been refreshed.';
continue;
}
case 'error.relayClientForbidden':
disconnectReason = dev_tunnels_ssh_1.SshDisconnectReason.authCancelledByUser;
throwError(`Forbidden${statusCodeText}. Provide a fresh tunnel access token with '${this.tunnelSession.tunnelAccessScope}' scope.`);
break;
case 'error.tunnelPortNotFound':
throwError(`The tunnel or port is not found${statusCodeText}`);
break;
// Normally nginx choses another healthy pod when it cannot establish connection to a pod.
// However, if there are no other pods, it may returns 502 (Bad Gateway) to the client.
// This rare case may happen when the cluster recovers from a failure
// and the nginx controller has started but Relay service has not yet.
// 503 (Service Unavailable) can happen when Relay calls control plane to authenticate the request,
// control plane hits 429s from Cosmos DB and replies back with 503.
// 429 (Too Many Requests) can happen if client exceeds request rate limits.
case 'error.badGateway':
case 'error.serviceUnavailable':
case 'error.tooManyRequests':
errorDescription = errorDescription === 'error.tooManyRequests'
? `Rate limit exceeded${statusCodeText}. Too many requests in a given amount of time.`
: `Service temporarily unavailable${statusCodeText}`;
disconnectReason = dev_tunnels_ssh_1.SshDisconnectReason.serviceNotAvailable;
// Do not attempt more than 3 times to not overwhelm the service.
if (attempt > 3) {
throwError(errorDescription);
}
// Increase the attempt delay to reduce load on the service and let it recover.
if (attemptDelayMs < exports.maxReconnectDelayMs / 2) {
attemptDelayMs = exports.maxReconnectDelayMs / 2;
}
continue;
default:
if (errorDescription === null || errorDescription === void 0 ? void 0 : errorDescription.startsWith('error.relayConnectionError ')) {
const recoverableError = recoverableNetworkErrors.find((s) => errorDescription.includes(s));
if (recoverableError) {
errorDescription = `Failed to connect to Relay server: ${recoverableError}`;
continue;
}
}
}
}
// Everything else is not recoverable
throw e;
}
finally {
// Graft SSH disconnect reason on to the error object as 'reason' property.
if (error && disconnectReason && !error.reason) {
error.reason = disconnectReason;
}
if (disconnectReason) {
await this.tunnelSession.closeSession(disconnectReason, error);
}
if (stream) {
await stream.close(error);
}
}
}
}
finally {
this.tunnelSession.finishConnecting(disconnectReason, error);
}
}
}
exports.RelayTunnelConnector = RelayTunnelConnector;
const recoverableNetworkErrors = [
'ECONNRESET',
'ENOTFOUND',
'ESOCKETTIMEDOUT',
'ETIMEDOUT',
'ECONNREFUSED',
'EHOSTUNREACH',
'EPIPE',
'EAI_AGAIN',
'EBUSY',
];
//# sourceMappingURL=relayTunnelConnector.js.map