Skip to content

Commit

Permalink
Keep track of reconnection atempts in delay calc
Browse files Browse the repository at this point in the history
Track how many times Sarus has already tried to (re)connect.
When exponential backoff is enabled, use the stored number of connection
attempts to calculate the exponential delay.
  • Loading branch information
justuswilhelm committed Apr 19, 2024
1 parent 11355ad commit 037e01b
Show file tree
Hide file tree
Showing 3 changed files with 125 additions and 27 deletions.
73 changes: 57 additions & 16 deletions __tests__/index/retryConnectionDelay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,28 +65,69 @@ describe("retry connection delay", () => {
});

describe("Exponential backoff delay", () => {
it("will never be more than 8000 ms with rate set to 2", () => {
describe("with rate 2, backoffLimit 8000 ms", () => {
// 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);
it("will never be more than 8000 ms with rate set to 2", () => {
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);
});

it("should delay reconnection attempts exponentially", async () => {
let setTimeoutCallback: any = undefined;
// The mocking here is a bit elaborate. We want to test the state machine
// in isolation.
const wsMock: any = {};
(global as any).WebSocket = () => wsMock;
(global as any).setTimeout = (fn: () => void, delay: number) => {
setTimeoutCallback = fn;
};
const sarus = new Sarus({
url,
exponentialBackoff: exponentialBackoffParams,
});
expect(sarus.state.kind).toBe("connecting");
expect(wsMock.onopen).toBeTruthy();
wsMock.onopen();
expect(sarus.state.kind).toBe("connected");
expect(setTimeoutCallback).toBeUndefined();
wsMock.onclose();
expect(sarus.state).toStrictEqual({
kind: "closed",
failedConnectionAttempts: 0,
});
expect(setTimeoutCallback).not.toBeUndefined();
// expect ... to be called with N ms
if (setTimeoutCallback) {
setTimeoutCallback();
}
expect(sarus.state).toStrictEqual({
kind: "connecting",
failedConnectionAttempts: 0,
});
wsMock.onclose();
expect(sarus.state).toStrictEqual({
kind: "connecting",
failedConnectionAttempts: 1,
});
});
});
});
25 changes: 19 additions & 6 deletions __tests__/index/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ describe("state machine", () => {

// In the beginning, the state is "connecting"
const sarus: Sarus = new Sarus(sarusConfig);
expect(sarus.state.kind).toBe("connecting");
// Since Sarus jumps into connecting directly, 1 connection attempt is made
// right in the beginning, but none have failed
expect(sarus.state).toStrictEqual({
kind: "connecting",
failedConnectionAttempts: 0,
});

// We wait until we are connected, and see a "connected" state
await server.connected;
Expand All @@ -24,14 +29,22 @@ describe("state machine", () => {
// When the connection drops, the state will be "closed"
server.close();
await server.closed;
expect(sarus.state.kind).toBe("closed");

// Restart server
server = new WS(url);
expect(sarus.state).toStrictEqual({
kind: "closed",
failedConnectionAttempts: 0,
});

// We wait a while, and the status is "connecting" again
await delay(1);
expect(sarus.state.kind).toBe("connecting");
// In the beginning, no connection attempts have been made, since in the
// case of a closed connection, we wait a bit until we try to connect again.
expect(sarus.state).toStrictEqual({
kind: "connecting",
failedConnectionAttempts: 0,
});

// We restart the server and let the Sarus instance reconnect:
server = new WS(url);

// When we connect in our mock server, we are "connected" again
await server.connected;
Expand Down
54 changes: 49 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,16 @@ export default class Sarus {
* after the constructor wraps up.
*/
state:
| { kind: "connecting" }
| { kind: "connecting"; failedConnectionAttempts: number }
| { kind: "connected" }
| { kind: "disconnected" }
| { kind: "closed" } = { kind: "connecting" };
/**
* The closed state carries of the number of failed connection attempts
*/
| { kind: "closed"; failedConnectionAttempts: number } = {
kind: "connecting",
failedConnectionAttempts: 0,
};

constructor(props: SarusClassParams) {
// Extract the properties that are passed to the class
Expand Down Expand Up @@ -378,7 +384,19 @@ export default class Sarus {
* Connects the WebSocket client, and attaches event listeners
*/
connect() {
this.state = { kind: "connecting" };
if (this.state.kind === "closed") {
this.state = {
kind: "connecting",
failedConnectionAttempts: this.state.failedConnectionAttempts,
};
} else if (
this.state.kind === "connected" ||
this.state.kind === "disconnected"
) {
this.state = { kind: "connecting", failedConnectionAttempts: 0 };
} else {
// This is a NOOP, we are already connecting
}
this.ws = new WebSocket(this.url, this.protocols);
this.setBinaryType();
this.attachEventListeners();
Expand All @@ -392,11 +410,22 @@ export default class Sarus {
reconnect() {
const self = this;
const { retryConnectionDelay, exponentialBackoff } = self;
// If we are already in a "connecting" state, we need to refer to the
// current amount of connection attemps to correctly calculate the
// exponential delay -- if exponential backoff is enabled.
const failedConnectionAttempts =
self.state.kind === "connecting"
? self.state.failedConnectionAttempts
: 0;

// 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)
? calculateRetryDelayFactor(
exponentialBackoff,
retryConnectionDelay,
failedConnectionAttempts,
)
: retryConnectionDelay;

setTimeout(self.connect, delay);
Expand Down Expand Up @@ -544,7 +573,22 @@ export default class Sarus {
if (eventName === "open") {
self.state = { kind: "connected" };
} else if (eventName === "close" && self.reconnectAutomatically) {
self.state = { kind: "closed" };
const { state } = self;
// If we have previously been "connecting", we carry over the amount
// of failed connection attempts and add 1, since the current
// connection attempt failed. We stay "connecting" instead of
// "closed", since we've never been fully "connected" in the first
// place.
if (state.kind === "connecting") {
self.state = {
kind: "connecting",
failedConnectionAttempts: state.failedConnectionAttempts + 1,
};
} else {
// If we were in a different state, we assume that our connection
// freshly closed and have not made any failed connection attempts.
self.state = { kind: "closed", failedConnectionAttempts: 0 };
}
self.removeEventListeners();
self.reconnect();
}
Expand Down

0 comments on commit 037e01b

Please sign in to comment.