Skip to content
Snippets Groups Projects
Commit ca48afad authored by Paul Kaplan's avatar Paul Kaplan
Browse files

Add exponential backoff for cloud provider.

Uses randomized duration between [0, 2^k-1] where k is the connection attempt number, maxed out at 5.

I had to rework the tests quite a bit to make this easy to test, to work around the timeouts and the randomization.
parent 533c16d5
No related branches found
No related tags found
No related merge requests found
...@@ -16,24 +16,26 @@ class CloudProvider { ...@@ -16,24 +16,26 @@ class CloudProvider {
this.vm = vm; this.vm = vm;
this.username = username; this.username = username;
this.projectId = projectId; this.projectId = projectId;
this.cloudHost = cloudHost;
// Open a websocket connection to the clouddata server this.connectionAttempts = 0;
this.openConnection(cloudHost);
this.openConnection();
} }
/** /**
* Open a new websocket connection to the clouddata server. * Open a new websocket connection to the clouddata server.
* @param {string} cloudHost The cloud data server to connect to. * @param {string} cloudHost The cloud data server to connect to.
*/ */
openConnection (cloudHost) { openConnection () {
if (window.WebSocket === null) { try {
log.warn('Websocket support is not available in this browser'); this.connection = new WebSocket((location.protocol === 'http:' ? 'ws://' : 'wss://') + this.cloudHost);
} catch (e) {
log.warn('Websocket support is not available in this browser', e);
this.connection = null; this.connection = null;
return; return;
} }
this.connection = new WebSocket((location.protocol === 'http:' ? 'ws://' : 'wss://') + cloudHost);
this.connection.onerror = this.onError.bind(this); this.connection.onerror = this.onError.bind(this);
this.connection.onmessage = this.onMessage.bind(this); this.connection.onmessage = this.onMessage.bind(this);
this.connection.onopen = this.onOpen.bind(this); this.connection.onopen = this.onOpen.bind(this);
...@@ -42,8 +44,7 @@ class CloudProvider { ...@@ -42,8 +44,7 @@ class CloudProvider {
onError (event) { onError (event) {
log.error(`Websocket connection error: ${JSON.stringify(event)}`); log.error(`Websocket connection error: ${JSON.stringify(event)}`);
// TODO Add re-connection attempt logic here // Error is always followed by close, which handles reconnect logic.
this.clear();
} }
onMessage (event) { onMessage (event) {
...@@ -57,12 +58,31 @@ class CloudProvider { ...@@ -57,12 +58,31 @@ class CloudProvider {
} }
onOpen () { onOpen () {
this.connectionAttempts = 1; // Reset because we successfully connected
this.writeToServer('handshake'); this.writeToServer('handshake');
log.info(`Successfully connected to clouddata server.`); log.info(`Successfully connected to clouddata server.`);
} }
onClose () { onClose () {
log.info(`Closed connection to websocket`); log.info(`Closed connection to websocket`);
const exponentialTimeout = (Math.pow(2, this.connectionAttempts) - 1) * 1000;
const randomizedTimeout = this.randomizeDuration(exponentialTimeout);
this.setTimeout(this.reconnectNow.bind(this), randomizedTimeout);
}
reconnectNow () {
// Max connection attempts at 5, so timeout will max out in range [0, 31s]
this.connectionAttempts = Math.min(this.connectionAttempts + 1, 5);
this.openConnection();
}
randomizeDuration (t) {
return Math.random() * t;
}
setTimeout (fn, time) {
log.info(`Reconnecting in ${time}ms, attempt ${this.connectionAttempts}`);
this._connectionTimeout = window.setTimeout(fn, time);
} }
parseMessage (message) { parseMessage (message) {
...@@ -148,7 +168,7 @@ class CloudProvider { ...@@ -148,7 +168,7 @@ class CloudProvider {
if (this.connection && if (this.connection &&
this.connection.readyState !== WebSocket.CLOSING && this.connection.readyState !== WebSocket.CLOSING &&
this.connection.readyState !== WebSocket.CLOSED) { this.connection.readyState !== WebSocket.CLOSED) {
this.connection.onclose = () => {}; // Remove close listener to prevent reconnect
this.connection.close(); this.connection.close();
} }
this.clear(); this.clear();
...@@ -163,6 +183,10 @@ class CloudProvider { ...@@ -163,6 +183,10 @@ class CloudProvider {
this.vm = null; this.vm = null;
this.username = null; this.username = null;
this.projectId = null; this.projectId = null;
if (this._connectionTimeout) {
clearTimeout(this._connectionTimeout);
this._connectionTimeout = null;
}
} }
} }
......
import CloudProvider from '../../../src/lib/cloud-provider'; import CloudProvider from '../../../src/lib/cloud-provider';
// Disable window.WebSocket let websocketConstructorCount = 0;
global.WebSocket = null;
// Stub the global websocket so we can call open/close/error/send on it
global.WebSocket = function (url) {
this._url = url;
this._sentMessages = [];
// These are not real websocket methods, but used to trigger callbacks
this._open = () => this.onopen();
this._error = e => this.onerror(e);
this._receive = msg => this.onmessage(msg);
// Stub the real websocket.send to store sent messages
this.send = msg => this._sentMessages.push(msg);
this.close = () => this.onclose();
websocketConstructorCount++;
};
global.WebSocket.CLOSING = 'CLOSING';
global.WebSocket.CLOSED = 'CLOSED';
describe('CloudProvider', () => { describe('CloudProvider', () => {
let cloudProvider = null; let cloudProvider = null;
let sentMessage = null;
let vmIOData = []; let vmIOData = [];
let timeout = 0;
beforeEach(() => { beforeEach(() => {
vmIOData = []; vmIOData = [];
cloudProvider = new CloudProvider(); cloudProvider = new CloudProvider();
// Stub connection
cloudProvider.connection = {
send: msg => {
sentMessage = msg;
}
};
// Stub vm // Stub vm
cloudProvider.vm = { cloudProvider.vm = {
postIOData: (_namespace, data) => { postIOData: (_namespace, data) => {
vmIOData.push(data); vmIOData.push(data);
} }
}; };
// Stub setTimeout so this can run instantly.
cloudProvider.setTimeout = (fn, after) => {
timeout = after;
fn();
};
// Stub randomize to make it consistent for testing.
cloudProvider.randomizeDuration = t => t;
}); });
test('updateVariable', () => { test('updateVariable', () => {
cloudProvider.updateVariable('hello', 1); cloudProvider.updateVariable('hello', 1);
const obj = JSON.parse(sentMessage); const obj = JSON.parse(cloudProvider.connection._sentMessages[0]);
expect(obj.method).toEqual('set'); expect(obj.method).toEqual('set');
expect(obj.name).toEqual('hello'); expect(obj.name).toEqual('hello');
expect(obj.value).toEqual(1); expect(obj.value).toEqual(1);
...@@ -35,7 +53,7 @@ describe('CloudProvider', () => { ...@@ -35,7 +53,7 @@ describe('CloudProvider', () => {
test('updateVariable with falsey value', () => { test('updateVariable with falsey value', () => {
cloudProvider.updateVariable('hello', 0); cloudProvider.updateVariable('hello', 0);
const obj = JSON.parse(sentMessage); const obj = JSON.parse(cloudProvider.connection._sentMessages[0]);
expect(obj.method).toEqual('set'); expect(obj.method).toEqual('set');
expect(obj.name).toEqual('hello'); expect(obj.name).toEqual('hello');
expect(obj.value).toEqual(0); expect(obj.value).toEqual(0);
...@@ -43,7 +61,7 @@ describe('CloudProvider', () => { ...@@ -43,7 +61,7 @@ describe('CloudProvider', () => {
test('writeToServer with falsey index value', () => { test('writeToServer with falsey index value', () => {
cloudProvider.writeToServer('method', 'name', 5, 0); cloudProvider.writeToServer('method', 'name', 5, 0);
const obj = JSON.parse(sentMessage); const obj = JSON.parse(cloudProvider.connection._sentMessages[0]);
expect(obj.method).toEqual('method'); expect(obj.method).toEqual('method');
expect(obj.name).toEqual('name'); expect(obj.name).toEqual('name');
expect(obj.value).toEqual(5); expect(obj.value).toEqual(5);
...@@ -55,7 +73,7 @@ describe('CloudProvider', () => { ...@@ -55,7 +73,7 @@ describe('CloudProvider', () => {
method: 'ack', method: 'ack',
name: 'name' name: 'name'
}); });
cloudProvider.onMessage({data: msg}); cloudProvider.connection._receive({data: msg});
expect(vmIOData[0].varCreate.name).toEqual('name'); expect(vmIOData[0].varCreate.name).toEqual('name');
}); });
...@@ -65,7 +83,7 @@ describe('CloudProvider', () => { ...@@ -65,7 +83,7 @@ describe('CloudProvider', () => {
name: 'name', name: 'name',
value: 'value' value: 'value'
}); });
cloudProvider.onMessage({data: msg}); cloudProvider.connection._receive({data: msg});
expect(vmIOData[0].varUpdate.name).toEqual('name'); expect(vmIOData[0].varUpdate.name).toEqual('name');
expect(vmIOData[0].varUpdate.value).toEqual('value'); expect(vmIOData[0].varUpdate.value).toEqual('value');
}); });
...@@ -80,8 +98,60 @@ describe('CloudProvider', () => { ...@@ -80,8 +98,60 @@ describe('CloudProvider', () => {
method: 'ack', method: 'ack',
name: 'name2' name: 'name2'
}); });
cloudProvider.onMessage({data: `${msg1}\n${msg2}`}); cloudProvider.connection._receive({data: `${msg1}\n${msg2}`});
expect(vmIOData[0].varUpdate.name).toEqual('name1'); expect(vmIOData[0].varUpdate.name).toEqual('name1');
expect(vmIOData[1].varCreate.name).toEqual('name2'); expect(vmIOData[1].varCreate.name).toEqual('name2');
}); });
test('connecting sets connnection attempts back to 1', () => {
expect(cloudProvider.connectionAttempts).toBe(0);
cloudProvider.connectionAttempts = 10;
cloudProvider.connection._open();
expect(cloudProvider.connectionAttempts).toBe(1);
});
test('disconnect waits for a period equal to 2^k-1 before trying again', () => {
websocketConstructorCount = 1; // This is global, so set it back to 1 to start
// Connection attempts should still be 0 because connection hasn't opened yet
expect(cloudProvider.connectionAttempts).toBe(0);
cloudProvider.connection._open();
expect(cloudProvider.connectionAttempts).toBe(1);
cloudProvider.connection.close();
expect(timeout).toEqual(1 * 1000); // 2^1 - 1
expect(websocketConstructorCount).toBe(2);
expect(cloudProvider.connectionAttempts).toBe(2);
cloudProvider.connection.close();
expect(timeout).toEqual(3 * 1000); // 2^2 - 1
expect(websocketConstructorCount).toBe(3);
expect(cloudProvider.connectionAttempts).toBe(3);
cloudProvider.connection.close();
expect(timeout).toEqual(7 * 1000); // 2^3 - 1
expect(websocketConstructorCount).toBe(4);
expect(cloudProvider.connectionAttempts).toBe(4);
cloudProvider.connection.close();
expect(timeout).toEqual(15 * 1000); // 2^4 - 1
expect(websocketConstructorCount).toBe(5);
expect(cloudProvider.connectionAttempts).toBe(5);
cloudProvider.connection.close();
expect(timeout).toEqual(31 * 1000); // 2^5 - 1
expect(websocketConstructorCount).toBe(6);
expect(cloudProvider.connectionAttempts).toBe(5); // Maxed at 5
cloudProvider.connection.close();
expect(timeout).toEqual(31 * 1000); // maxed out at 2^5 - 1
expect(websocketConstructorCount).toBe(7);
expect(cloudProvider.connectionAttempts).toBe(5); // Maxed at 5
});
test('requestCloseConnection does not try to reconnect', () => {
websocketConstructorCount = 1; // This is global, so set it back to 1 to start
cloudProvider.requestCloseConnection();
expect(websocketConstructorCount).toBe(1); // No reconnection attempts
});
}); });
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment