Skip to content

Commit 037e01b

Browse files
committed
Keep track of reconnection atempts in delay calc
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.
1 parent 11355ad commit 037e01b

File tree

3 files changed

+125
-27
lines changed

3 files changed

+125
-27
lines changed

__tests__/index/retryConnectionDelay.test.ts

+57-16
Original file line numberDiff line numberDiff line change
@@ -65,28 +65,69 @@ describe("retry connection delay", () => {
6565
});
6666

6767
describe("Exponential backoff delay", () => {
68-
it("will never be more than 8000 ms with rate set to 2", () => {
68+
describe("with rate 2, backoffLimit 8000 ms", () => {
6969
// The initial delay shall be 1 s
7070
const initialDelay = 1000;
7171
const exponentialBackoffParams: ExponentialBackoffParams = {
7272
backoffRate: 2,
7373
// We put the ceiling at exactly 8000 ms
7474
backoffLimit: 8000,
7575
};
76-
expect(
77-
calculateRetryDelayFactor(exponentialBackoffParams, initialDelay, 0),
78-
).toBe(1000);
79-
expect(
80-
calculateRetryDelayFactor(exponentialBackoffParams, initialDelay, 1),
81-
).toBe(2000);
82-
expect(
83-
calculateRetryDelayFactor(exponentialBackoffParams, initialDelay, 2),
84-
).toBe(4000);
85-
expect(
86-
calculateRetryDelayFactor(exponentialBackoffParams, initialDelay, 3),
87-
).toBe(8000);
88-
expect(
89-
calculateRetryDelayFactor(exponentialBackoffParams, initialDelay, 4),
90-
).toBe(8000);
76+
it("will never be more than 8000 ms with rate set to 2", () => {
77+
expect(
78+
calculateRetryDelayFactor(exponentialBackoffParams, initialDelay, 0),
79+
).toBe(1000);
80+
expect(
81+
calculateRetryDelayFactor(exponentialBackoffParams, initialDelay, 1),
82+
).toBe(2000);
83+
expect(
84+
calculateRetryDelayFactor(exponentialBackoffParams, initialDelay, 2),
85+
).toBe(4000);
86+
expect(
87+
calculateRetryDelayFactor(exponentialBackoffParams, initialDelay, 3),
88+
).toBe(8000);
89+
expect(
90+
calculateRetryDelayFactor(exponentialBackoffParams, initialDelay, 4),
91+
).toBe(8000);
92+
});
93+
94+
it("should delay reconnection attempts exponentially", async () => {
95+
let setTimeoutCallback: any = undefined;
96+
// The mocking here is a bit elaborate. We want to test the state machine
97+
// in isolation.
98+
const wsMock: any = {};
99+
(global as any).WebSocket = () => wsMock;
100+
(global as any).setTimeout = (fn: () => void, delay: number) => {
101+
setTimeoutCallback = fn;
102+
};
103+
const sarus = new Sarus({
104+
url,
105+
exponentialBackoff: exponentialBackoffParams,
106+
});
107+
expect(sarus.state.kind).toBe("connecting");
108+
expect(wsMock.onopen).toBeTruthy();
109+
wsMock.onopen();
110+
expect(sarus.state.kind).toBe("connected");
111+
expect(setTimeoutCallback).toBeUndefined();
112+
wsMock.onclose();
113+
expect(sarus.state).toStrictEqual({
114+
kind: "closed",
115+
failedConnectionAttempts: 0,
116+
});
117+
expect(setTimeoutCallback).not.toBeUndefined();
118+
// expect ... to be called with N ms
119+
if (setTimeoutCallback) {
120+
setTimeoutCallback();
121+
}
122+
expect(sarus.state).toStrictEqual({
123+
kind: "connecting",
124+
failedConnectionAttempts: 0,
125+
});
126+
wsMock.onclose();
127+
expect(sarus.state).toStrictEqual({
128+
kind: "connecting",
129+
failedConnectionAttempts: 1,
130+
});
131+
});
91132
});
92133
});

__tests__/index/state.test.ts

+19-6
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ describe("state machine", () => {
1515

1616
// In the beginning, the state is "connecting"
1717
const sarus: Sarus = new Sarus(sarusConfig);
18-
expect(sarus.state.kind).toBe("connecting");
18+
// Since Sarus jumps into connecting directly, 1 connection attempt is made
19+
// right in the beginning, but none have failed
20+
expect(sarus.state).toStrictEqual({
21+
kind: "connecting",
22+
failedConnectionAttempts: 0,
23+
});
1924

2025
// We wait until we are connected, and see a "connected" state
2126
await server.connected;
@@ -24,14 +29,22 @@ describe("state machine", () => {
2429
// When the connection drops, the state will be "closed"
2530
server.close();
2631
await server.closed;
27-
expect(sarus.state.kind).toBe("closed");
28-
29-
// Restart server
30-
server = new WS(url);
32+
expect(sarus.state).toStrictEqual({
33+
kind: "closed",
34+
failedConnectionAttempts: 0,
35+
});
3136

3237
// We wait a while, and the status is "connecting" again
3338
await delay(1);
34-
expect(sarus.state.kind).toBe("connecting");
39+
// In the beginning, no connection attempts have been made, since in the
40+
// case of a closed connection, we wait a bit until we try to connect again.
41+
expect(sarus.state).toStrictEqual({
42+
kind: "connecting",
43+
failedConnectionAttempts: 0,
44+
});
45+
46+
// We restart the server and let the Sarus instance reconnect:
47+
server = new WS(url);
3548

3649
// When we connect in our mock server, we are "connected" again
3750
await server.connected;

src/index.ts

+49-5
Original file line numberDiff line numberDiff line change
@@ -180,10 +180,16 @@ export default class Sarus {
180180
* after the constructor wraps up.
181181
*/
182182
state:
183-
| { kind: "connecting" }
183+
| { kind: "connecting"; failedConnectionAttempts: number }
184184
| { kind: "connected" }
185185
| { kind: "disconnected" }
186-
| { kind: "closed" } = { kind: "connecting" };
186+
/**
187+
* The closed state carries of the number of failed connection attempts
188+
*/
189+
| { kind: "closed"; failedConnectionAttempts: number } = {
190+
kind: "connecting",
191+
failedConnectionAttempts: 0,
192+
};
187193

188194
constructor(props: SarusClassParams) {
189195
// Extract the properties that are passed to the class
@@ -378,7 +384,19 @@ export default class Sarus {
378384
* Connects the WebSocket client, and attaches event listeners
379385
*/
380386
connect() {
381-
this.state = { kind: "connecting" };
387+
if (this.state.kind === "closed") {
388+
this.state = {
389+
kind: "connecting",
390+
failedConnectionAttempts: this.state.failedConnectionAttempts,
391+
};
392+
} else if (
393+
this.state.kind === "connected" ||
394+
this.state.kind === "disconnected"
395+
) {
396+
this.state = { kind: "connecting", failedConnectionAttempts: 0 };
397+
} else {
398+
// This is a NOOP, we are already connecting
399+
}
382400
this.ws = new WebSocket(this.url, this.protocols);
383401
this.setBinaryType();
384402
this.attachEventListeners();
@@ -392,11 +410,22 @@ export default class Sarus {
392410
reconnect() {
393411
const self = this;
394412
const { retryConnectionDelay, exponentialBackoff } = self;
413+
// If we are already in a "connecting" state, we need to refer to the
414+
// current amount of connection attemps to correctly calculate the
415+
// exponential delay -- if exponential backoff is enabled.
416+
const failedConnectionAttempts =
417+
self.state.kind === "connecting"
418+
? self.state.failedConnectionAttempts
419+
: 0;
395420

396421
// If no exponential backoff is enabled, retryConnectionDelay will
397422
// be scaled by a factor of 1 and it will stay the original value.
398423
const delay = exponentialBackoff
399-
? calculateRetryDelayFactor(exponentialBackoff, retryConnectionDelay, 0)
424+
? calculateRetryDelayFactor(
425+
exponentialBackoff,
426+
retryConnectionDelay,
427+
failedConnectionAttempts,
428+
)
400429
: retryConnectionDelay;
401430

402431
setTimeout(self.connect, delay);
@@ -544,7 +573,22 @@ export default class Sarus {
544573
if (eventName === "open") {
545574
self.state = { kind: "connected" };
546575
} else if (eventName === "close" && self.reconnectAutomatically) {
547-
self.state = { kind: "closed" };
576+
const { state } = self;
577+
// If we have previously been "connecting", we carry over the amount
578+
// of failed connection attempts and add 1, since the current
579+
// connection attempt failed. We stay "connecting" instead of
580+
// "closed", since we've never been fully "connected" in the first
581+
// place.
582+
if (state.kind === "connecting") {
583+
self.state = {
584+
kind: "connecting",
585+
failedConnectionAttempts: state.failedConnectionAttempts + 1,
586+
};
587+
} else {
588+
// If we were in a different state, we assume that our connection
589+
// freshly closed and have not made any failed connection attempts.
590+
self.state = { kind: "closed", failedConnectionAttempts: 0 };
591+
}
548592
self.removeEventListeners();
549593
self.reconnect();
550594
}

0 commit comments

Comments
 (0)