UNPKG

@microsoft/dev-tunnels-connections

Version:

Tunnels library for Visual Studio tools

247 lines 13.6 kB
"use strict"; // 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