Skip to content

Commit 56b6096

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 6f46ee0 commit 56b6096

File tree

3 files changed

+141
-27
lines changed

3 files changed

+141
-27
lines changed

__tests__/index/retryConnectionDelay.test.ts

+73-16
Original file line numberDiff line numberDiff line change
@@ -65,28 +65,85 @@ 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+
const webSocketSpy = jest.spyOn(global, "WebSocket" as any);
96+
webSocketSpy.mockImplementation(() => {});
97+
const setTimeoutSpy = jest.spyOn(global, "setTimeout");
98+
const sarus = new Sarus({
99+
url,
100+
exponentialBackoff: exponentialBackoffParams,
101+
});
102+
expect(sarus.state).toStrictEqual({
103+
kind: "connecting",
104+
failedConnectionAttempts: 0,
105+
});
106+
let instance: WebSocket;
107+
[instance] = webSocketSpy.mock.instances;
108+
if (!instance.onopen) {
109+
throw new Error();
110+
}
111+
instance.onopen(new Event("open"));
112+
if (!instance.onclose) {
113+
throw new Error();
114+
}
115+
instance.onclose(new CloseEvent("close"));
116+
117+
let cb: Sarus["connect"];
118+
// We iteratively call sarus.connect() and let it fail, seeing
119+
// if it reaches 8000 as a delay and stays there
120+
const attempts: [number, number][] = [
121+
[1000, 1],
122+
[2000, 2],
123+
[4000, 3],
124+
[8000, 4],
125+
[8000, 5],
126+
];
127+
attempts.forEach(([delay, failedAttempts]: [number, number]) => {
128+
const call =
129+
setTimeoutSpy.mock.calls[setTimeoutSpy.mock.calls.length - 1];
130+
if (!call) {
131+
throw new Error();
132+
}
133+
expect(call[1]).toBe(delay);
134+
cb = call[0];
135+
cb();
136+
instance =
137+
webSocketSpy.mock.instances[webSocketSpy.mock.instances.length - 1];
138+
if (!instance.onclose) {
139+
throw new Error();
140+
}
141+
instance.onclose(new CloseEvent("close"));
142+
expect(sarus.state).toStrictEqual({
143+
kind: "connecting",
144+
failedConnectionAttempts: failedAttempts,
145+
});
146+
});
147+
});
91148
});
92149
});

__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
@@ -179,10 +179,16 @@ export default class Sarus {
179179
* after the constructor wraps up.
180180
*/
181181
state:
182-
| { kind: "connecting" }
182+
| { kind: "connecting"; failedConnectionAttempts: number }
183183
| { kind: "connected" }
184184
| { kind: "disconnected" }
185-
| { kind: "closed" } = { kind: "connecting" };
185+
/**
186+
* The closed state carries of the number of failed connection attempts
187+
*/
188+
| { kind: "closed"; failedConnectionAttempts: number } = {
189+
kind: "connecting",
190+
failedConnectionAttempts: 0,
191+
};
186192

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

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

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

0 commit comments

Comments
 (0)