Skip to content

Commit

Permalink
Add optional exponential backoff for reconnects
Browse files Browse the repository at this point in the history
Allow a user to configure and enable exponential backoff for failing
connections.

The new configuration parameter is called `exponentialBackoff` and
requires two parameters `backoffRate` and `backoffLimit`.
  • Loading branch information
justuswilhelm committed Apr 19, 2024
1 parent 3b8a231 commit 7245945
Show file tree
Hide file tree
Showing 7 changed files with 224 additions and 32 deletions.
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions __tests__/index/reconnectivity.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
});
});
17 changes: 13 additions & 4 deletions dist/index.d.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -7,6 +12,7 @@ export interface SarusClassParams {
retryProcessTimePeriod?: number;
reconnectAutomatically?: boolean;
retryConnectionDelay?: boolean | number;
exponentialBackoff?: ExponentialBackoffParams;
storageType?: string;
storageKey?: string;
}
Expand All @@ -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<string>;
eventListeners: EventListenersInterface;
retryProcessTimePeriod?: number;
reconnectAutomatically?: boolean;
retryConnectionDelay?: boolean | number;
retryConnectionDelay: number;
exponentialBackoff?: ExponentialBackoffParams;
storageType: string;
storageKey: string;
messageStore: any;
Expand Down Expand Up @@ -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;
/**
Expand Down
87 changes: 65 additions & 22 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -106,15 +155,15 @@ 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;
/*
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.
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions dist/lib/constants.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EventListenersInterface } from "./validators";
export declare const ALLOWED_PROTOCOLS: Array<string>;
/**
* A definitive list of events for a WebSocket client to listen on
* @constant
Expand Down
7 changes: 4 additions & 3 deletions dist/lib/constants.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,7 +11,7 @@ exports.WS_EVENT_NAMES = [
"open",
"close",
"message",
"error"
"error",
];
/**
* Persistent data storage types
Expand All @@ -32,5 +33,5 @@ exports.DEFAULT_EVENT_LISTENERS_OBJECT = {
open: [],
message: [],
error: [],
close: []
close: [],
};
Loading

0 comments on commit 7245945

Please sign in to comment.