diff --git a/README.md b/README.md index e8ef7f3..edcab4c 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,68 @@ If you think that there is a potential case for you ending up queuing at least wrap `sarus.send` function calls in a try/catch statement, so as to handle those messages, should they occur. +### Exponential backoff + +Configure exponential backoff like so: + +```typescript +import Sarus from '@anephenix/sarus'; + +const sarus = new Sarus({ + url: 'wss://ws.anephenix.com', + exponentialBackoff: { + // Exponential factor, here 2 will result in + // 1 s, 2 s, 4 s, and so on increasing delays + backoffRate: 2, + // Never wait more than 2000 seconds + backoffLimit: 2000, + }, +}); +``` + +When a connection attempt repeatedly fails, decreasing the delay +exponentially between each subsequent reconnection attempt is called +[Exponential backoff](https://en.wikipedia.org/wiki/Exponential_backoff). The +idea is that if a connection attempt failed after 1 second, and 2 seconds, then it is +not necessary to check it on the 3rd second, since the probability of a +reconnection succeeding on the third attempt is most likely not going up. +Therefore, increasing the delay between each attempt factors in the assumption +that a connection is not more likely to succeed by repeatedly probing in regular +intervals. + +This decreases both the load on the client, as well as on the server. For +a client, fewer websocket connection attempts decrease the load on the client +and on the network connection. For the server, should websocket requests fail +within, then the load for handling repeatedly failing requests will fall +as well. Furthermore, the burden on the network will also be decreased. Should +for example a server refuse to accept websocket connections for one client, +then there is the possibility that other clients will also not be able to connect. + +Sarus implements _truncated exponential backoff_, meaning that the maximum +reconnection delay is capped by another factor `backoffLimit` and will never +exceed it. The exponential backoff rate itself is determined by `backoffRate`. +If `backoffRate` is 2, then the delays will be 1 s, 2 s, 4 s, and so on. + +The algorithm for reconnection looks like this in pseudocode: + +```javascript +// Configurable +const backoffRate = 2; +// The maximum delay will be 400s +const backoffLimit = 400; +let notConnected = false; +let connectionAttempts = 1; +while (notConnected) { + const delay = Math.min( + Math.pow(connectionAttempts, backoffRate), + backoffLimit, + ); + await delay(delay); + notConnected = tryToConnect(); + connectionAttempts += 1; +} +``` + ### Advanced options Sarus has a number of other options that you can pass to the client during diff --git a/__tests__/index/reconnectivity.test.ts b/__tests__/index/reconnectivity.test.ts index fffa4f0..838c62c 100644 --- a/__tests__/index/reconnectivity.test.ts +++ b/__tests__/index/reconnectivity.test.ts @@ -1,5 +1,7 @@ // File Dependencies import Sarus from "../../src/index"; +import { calculateRetryDelayFactor } from "../../src/index"; +import type { ExponentialBackoffParams } from "../../src/index"; import { WS } from "jest-websocket-mock"; import { delay } from "../helpers/delay"; @@ -55,3 +57,30 @@ describe("automatic reconnectivity", () => { }); }); }); + +describe("Exponential backoff", () => { + describe("binary backoff", () => { + // The initial delay shall be 1 s + const initialDelay = 1000; + const exponentialBackoffParams: ExponentialBackoffParams = { + backoffRate: 2, + // We put the ceiling at exactly 8000 ms + backoffLimit: 8000, + }; + expect( + calculateRetryDelayFactor(exponentialBackoffParams, initialDelay, 0), + ).toBe(1000); + expect( + calculateRetryDelayFactor(exponentialBackoffParams, initialDelay, 1), + ).toBe(2000); + expect( + calculateRetryDelayFactor(exponentialBackoffParams, initialDelay, 2), + ).toBe(4000); + expect( + calculateRetryDelayFactor(exponentialBackoffParams, initialDelay, 3), + ).toBe(8000); + expect( + calculateRetryDelayFactor(exponentialBackoffParams, initialDelay, 4), + ).toBe(8000); + }); +}); diff --git a/dist/index.d.ts b/dist/index.d.ts index b884b68..889089d 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,4 +1,9 @@ import { PartialEventListenersInterface, EventListenersInterface } from "./lib/validators"; +export interface ExponentialBackoffParams { + backoffRate: number; + backoffLimit: number; +} +export declare function calculateRetryDelayFactor(params: ExponentialBackoffParams, initialDelay: number, connectionAttempts: number): number; export interface SarusClassParams { url: string; binaryType?: BinaryType; @@ -7,6 +12,7 @@ export interface SarusClassParams { retryProcessTimePeriod?: number; reconnectAutomatically?: boolean; retryConnectionDelay?: boolean | number; + exponentialBackoff?: ExponentialBackoffParams; storageType?: string; storageKey?: string; } @@ -20,19 +26,21 @@ export interface SarusClassParams { * @param {object} param0.eventListeners - An optional object containing event listener functions keyed to websocket events * @param {boolean} param0.reconnectAutomatically - An optional boolean flag to indicate whether to reconnect automatically when a websocket connection is severed * @param {number} param0.retryProcessTimePeriod - An optional number for how long the time period between retrying to send a messgae to a WebSocket server should be - * @param {boolean|number} param0.retryConnectionDelay - An optional parameter for whether to delay WebSocket reconnection attempts by a time period. If true, the delay is 1000ms, otherwise it is the number passed + * @param {boolean|number} param0.retryConnectionDelay - An optional parameter for whether to delay WebSocket reconnection attempts by a time period. If true, the delay is 1000ms, otherwise it is the number passed. The default value when this parameter is undefined will be interpreted as 1000ms. + * @param {ExponentialBackoffParams} param0.exponentialBackoff - An optional containing configuration for exponential backoff. If this parameter is undefined, exponential backoff is disabled. The minimum delay is determined by retryConnectionDelay. If retryConnectionDelay is set is false, this setting will not be in effect. * @param {string} param0.storageType - An optional string specifying the type of storage to use for persisting messages in the message queue * @param {string} param0.storageKey - An optional string specifying the key used to store the messages data against in sessionStorage/localStorage * @returns {object} The class instance */ export default class Sarus { - url: string; + url: URL; binaryType?: BinaryType; protocols?: string | Array; eventListeners: EventListenersInterface; retryProcessTimePeriod?: number; reconnectAutomatically?: boolean; - retryConnectionDelay?: boolean | number; + retryConnectionDelay: number; + exponentialBackoff?: ExponentialBackoffParams; storageType: string; storageKey: string; messageStore: any; @@ -81,7 +89,8 @@ export default class Sarus { */ connect(): void; /** - * Reconnects the WebSocket client based on the retryConnectionDelay setting. + * Reconnects the WebSocket client based on the retryConnectionDelay and + * ExponentialBackoffParam setting. */ reconnect(): void; /** diff --git a/dist/index.js b/dist/index.js index da237b9..fded109 100644 --- a/dist/index.js +++ b/dist/index.js @@ -20,6 +20,7 @@ var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { return to.concat(ar || Array.prototype.slice.call(from)); }; Object.defineProperty(exports, "__esModule", { value: true }); +exports.calculateRetryDelayFactor = void 0; // File Dependencies var constants_1 = require("./lib/constants"); var dataTransformer_1 = require("./lib/dataTransformer"); @@ -52,6 +53,38 @@ var getMessagesFromStore = function (_a) { return (0, dataTransformer_1.deserialize)(rawData) || []; } }; +var validateWebSocketUrl = function (rawUrl) { + var url; + try { + // Alternatively, we can also check with URL.canParse(), but since we need + // the URL object anyway to validate the protocol, we go ahead and parse it + // here. + url = new URL(rawUrl); + } + catch (e) { + // TypeError, as specified by WHATWG URL Standard: + // https://url.spec.whatwg.org/#url-class (see constructor steps) + if (!(e instanceof TypeError)) { + throw e; + } + // Untested - our URL mock does not give us an instance of TypeError + var message = e.message; + throw new Error("The WebSocket URL is not valid: ".concat(message)); + } + var protocol = url.protocol; + if (!constants_1.ALLOWED_PROTOCOLS.includes(protocol)) { + throw new Error("Expected the WebSocket URL to have protocol 'ws:' or 'wss:', got '".concat(protocol, "' instead.")); + } + return url; +}; +/* + * Calculate the exponential backoff delay for a given number of connection + * attempts. + */ +function calculateRetryDelayFactor(params, initialDelay, connectionAttempts) { + return Math.min(initialDelay * Math.pow(params.backoffRate, connectionAttempts), params.backoffLimit); +} +exports.calculateRetryDelayFactor = calculateRetryDelayFactor; /** * The Sarus client class * @constructor @@ -62,19 +95,21 @@ var getMessagesFromStore = function (_a) { * @param {object} param0.eventListeners - An optional object containing event listener functions keyed to websocket events * @param {boolean} param0.reconnectAutomatically - An optional boolean flag to indicate whether to reconnect automatically when a websocket connection is severed * @param {number} param0.retryProcessTimePeriod - An optional number for how long the time period between retrying to send a messgae to a WebSocket server should be - * @param {boolean|number} param0.retryConnectionDelay - An optional parameter for whether to delay WebSocket reconnection attempts by a time period. If true, the delay is 1000ms, otherwise it is the number passed + * @param {boolean|number} param0.retryConnectionDelay - An optional parameter for whether to delay WebSocket reconnection attempts by a time period. If true, the delay is 1000ms, otherwise it is the number passed. The default value when this parameter is undefined will be interpreted as 1000ms. + * @param {ExponentialBackoffParams} param0.exponentialBackoff - An optional containing configuration for exponential backoff. If this parameter is undefined, exponential backoff is disabled. The minimum delay is determined by retryConnectionDelay. If retryConnectionDelay is set is false, this setting will not be in effect. * @param {string} param0.storageType - An optional string specifying the type of storage to use for persisting messages in the message queue * @param {string} param0.storageKey - An optional string specifying the key used to store the messages data against in sessionStorage/localStorage * @returns {object} The class instance */ var Sarus = /** @class */ (function () { function Sarus(props) { + var _a; // Extract the properties that are passed to the class - var url = props.url, binaryType = props.binaryType, protocols = props.protocols, _a = props.eventListeners, eventListeners = _a === void 0 ? constants_1.DEFAULT_EVENT_LISTENERS_OBJECT : _a, reconnectAutomatically = props.reconnectAutomatically, retryProcessTimePeriod = props.retryProcessTimePeriod, // TODO - write a test case to check this - retryConnectionDelay = props.retryConnectionDelay, _b = props.storageType, storageType = _b === void 0 ? "memory" : _b, _c = props.storageKey, storageKey = _c === void 0 ? "sarus" : _c; + var url = props.url, binaryType = props.binaryType, protocols = props.protocols, _b = props.eventListeners, eventListeners = _b === void 0 ? constants_1.DEFAULT_EVENT_LISTENERS_OBJECT : _b, reconnectAutomatically = props.reconnectAutomatically, retryProcessTimePeriod = props.retryProcessTimePeriod, // TODO - write a test case to check this + retryConnectionDelay = props.retryConnectionDelay, exponentialBackoff = props.exponentialBackoff, _c = props.storageType, storageType = _c === void 0 ? "memory" : _c, _d = props.storageKey, storageKey = _d === void 0 ? "sarus" : _d; this.eventListeners = this.auditEventListeners(eventListeners); // Sets the WebSocket server url for the client to connect to. - this.url = url; + this.url = validateWebSocketUrl(url); // Sets the binaryType of the data being sent over the connection this.binaryType = binaryType; // Sets an optional protocols value, which can be either a string or an array of strings @@ -96,7 +131,21 @@ var Sarus = /** @class */ (function () { client. If true, a 1000ms delay is added. If a number, that number (as miliseconds) is used as the delay. Default is true. */ - this.retryConnectionDelay = retryConnectionDelay || true; + // Either retryConnectionDelay is + // undefined => default to 1000 + // true => default to 1000 + // false => default to 1000 + // a number => set it to that number + this.retryConnectionDelay = + (_a = (typeof retryConnectionDelay === "boolean" + ? undefined + : retryConnectionDelay)) !== null && _a !== void 0 ? _a : 1000; + /* + When a exponential backoff parameter object is provided, reconnection + attemptions will be increasingly delayed by an exponential factor. + This feature is disabled by default. + */ + this.exponentialBackoff = exponentialBackoff; /* Sets the storage type for the messages in the message queue. By default it is an in-memory option, but can also be set as 'session' for @@ -106,7 +155,7 @@ var Sarus = /** @class */ (function () { /* When using 'session' or 'local' as the storageType, the storage key is used as the key for calls to sessionStorage/localStorage getItem/setItem. - + It can also be configured by the developer during initialization. */ this.storageKey = storageKey; @@ -114,7 +163,7 @@ var Sarus = /** @class */ (function () { When initializing the client, if we are using sessionStorage/localStorage for storing messages in the messageQueue, then we want to retrieve any that might have been persisted there. - + Say the user has done a page refresh, we want to make sure that messages that were meant to be sent to the server make their way there. @@ -224,24 +273,18 @@ var Sarus = /** @class */ (function () { this.process(); }; /** - * Reconnects the WebSocket client based on the retryConnectionDelay setting. + * Reconnects the WebSocket client based on the retryConnectionDelay and + * ExponentialBackoffParam setting. */ Sarus.prototype.reconnect = function () { var self = this; - var retryConnectionDelay = self.retryConnectionDelay; - switch (typeof retryConnectionDelay) { - case "boolean": - if (retryConnectionDelay) { - setTimeout(self.connect, 1000); - } - else { - self.connect(); // NOTE - this line is not tested - } - break; - case "number": - setTimeout(self.connect, retryConnectionDelay); - break; - } + var retryConnectionDelay = self.retryConnectionDelay, exponentialBackoff = self.exponentialBackoff; + // If no exponential backoff is enabled, retryConnectionDelay will + // be scaled by a factor of 1 and it will stay the original value. + var delay = exponentialBackoff + ? calculateRetryDelayFactor(exponentialBackoff, retryConnectionDelay, 0) + : retryConnectionDelay; + setTimeout(self.connect, delay); }; /** * Disconnects the WebSocket client from the server, and changes the diff --git a/dist/lib/constants.d.ts b/dist/lib/constants.d.ts index 13832d2..f2df83c 100644 --- a/dist/lib/constants.d.ts +++ b/dist/lib/constants.d.ts @@ -1,4 +1,5 @@ import { EventListenersInterface } from "./validators"; +export declare const ALLOWED_PROTOCOLS: Array; /** * A definitive list of events for a WebSocket client to listen on * @constant diff --git a/dist/lib/constants.js b/dist/lib/constants.js index 8fb5299..ca9252f 100644 --- a/dist/lib/constants.js +++ b/dist/lib/constants.js @@ -1,6 +1,7 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.DEFAULT_EVENT_LISTENERS_OBJECT = exports.DATA_STORAGE_TYPES = exports.WS_EVENT_NAMES = void 0; +exports.DEFAULT_EVENT_LISTENERS_OBJECT = exports.DATA_STORAGE_TYPES = exports.WS_EVENT_NAMES = exports.ALLOWED_PROTOCOLS = void 0; +exports.ALLOWED_PROTOCOLS = ["ws:", "wss:"]; /** * A definitive list of events for a WebSocket client to listen on * @constant @@ -10,7 +11,7 @@ exports.WS_EVENT_NAMES = [ "open", "close", "message", - "error" + "error", ]; /** * Persistent data storage types @@ -32,5 +33,5 @@ exports.DEFAULT_EVENT_LISTENERS_OBJECT = { open: [], message: [], error: [], - close: [] + close: [], }; diff --git a/src/index.ts b/src/index.ts index 585e433..9b3b315 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,6 +73,34 @@ const validateWebSocketUrl = (rawUrl: string): URL => { return url; }; +export interface ExponentialBackoffParams { + backoffRate: number; + backoffLimit: number; +} + +/* + * Calculate the exponential backoff delay for a given number of connection + * attempts. + * @param {ExponentialBackoffParams} params - configuration parameters for + * exponential backoff. + * @param {number} initialDelay - the initial delay before any backoff is + * applied + * @param {number} connectionAttempts - the number of connection attempts + * that have previously failed, excluding the original connection attempt that + * succeeded + * @returns {void} - set does not return + */ +export function calculateRetryDelayFactor( + params: ExponentialBackoffParams, + initialDelay: number, + connectionAttempts: number, +): number { + return Math.min( + initialDelay * Math.pow(params.backoffRate, connectionAttempts), + params.backoffLimit, + ); +} + export interface SarusClassParams { url: string; binaryType?: BinaryType; @@ -81,6 +109,7 @@ export interface SarusClassParams { retryProcessTimePeriod?: number; reconnectAutomatically?: boolean; retryConnectionDelay?: boolean | number; + exponentialBackoff?: ExponentialBackoffParams; storageType?: string; storageKey?: string; } @@ -96,6 +125,7 @@ export interface SarusClassParams { * @param {boolean} param0.reconnectAutomatically - An optional boolean flag to indicate whether to reconnect automatically when a websocket connection is severed * @param {number} param0.retryProcessTimePeriod - An optional number for how long the time period between retrying to send a messgae to a WebSocket server should be * @param {boolean|number} param0.retryConnectionDelay - An optional parameter for whether to delay WebSocket reconnection attempts by a time period. If true, the delay is 1000ms, otherwise it is the number passed. The default value when this parameter is undefined will be interpreted as 1000ms. + * @param {ExponentialBackoffParams} param0.exponentialBackoff - An optional containing configuration for exponential backoff. If this parameter is undefined, exponential backoff is disabled. The minimum delay is determined by retryConnectionDelay. If retryConnectionDelay is set is false, this setting will not be in effect. * @param {string} param0.storageType - An optional string specifying the type of storage to use for persisting messages in the message queue * @param {string} param0.storageKey - An optional string specifying the key used to store the messages data against in sessionStorage/localStorage * @returns {object} The class instance @@ -109,6 +139,7 @@ export default class Sarus { retryProcessTimePeriod?: number; reconnectAutomatically?: boolean; retryConnectionDelay: number; + exponentialBackoff?: ExponentialBackoffParams; storageType: string; storageKey: string; @@ -149,6 +180,7 @@ export default class Sarus { reconnectAutomatically, retryProcessTimePeriod, // TODO - write a test case to check this retryConnectionDelay, + exponentialBackoff, storageType = "memory", storageKey = "sarus", } = props; @@ -193,6 +225,13 @@ export default class Sarus { ? undefined : retryConnectionDelay) ?? 1000; + /* + When a exponential backoff parameter object is provided, reconnection + attemptions will be increasingly delayed by an exponential factor. + This feature is disabled by default. + */ + this.exponentialBackoff = exponentialBackoff; + /* Sets the storage type for the messages in the message queue. By default it is an in-memory option, but can also be set as 'session' for @@ -332,12 +371,20 @@ export default class Sarus { } /** - * Reconnects the WebSocket client based on the retryConnectionDelay setting. + * Reconnects the WebSocket client based on the retryConnectionDelay and + * ExponentialBackoffParam setting. */ reconnect() { const self = this; - const { retryConnectionDelay } = self; - setTimeout(self.connect, retryConnectionDelay); + const { retryConnectionDelay, exponentialBackoff } = self; + + // If no exponential backoff is enabled, retryConnectionDelay will + // be scaled by a factor of 1 and it will stay the original value. + const delay = exponentialBackoff + ? calculateRetryDelayFactor(exponentialBackoff, retryConnectionDelay, 0) + : retryConnectionDelay; + + setTimeout(self.connect, delay); } /**