backoff-web
Version:
Fibonacci and exponential backoffs.
403 lines (320 loc) • 12.4 kB
JavaScript
var events = require('events');
var sinon = require('sinon');
var util = require('util');
var FunctionCall = require('../src/function_call');
function MockBackoff () {
events.EventEmitter.call(this);
this.reset = sinon.spy();
this.backoff = sinon.spy();
this.failAfter = sinon.spy();
}
util.inherits(MockBackoff, events.EventEmitter);
exports['FunctionCall'] = {
setUp: function (callback) {
this.wrappedFn = sinon.stub();
this.callback = sinon.stub();
this.backoff = new MockBackoff();
this.backoffFactory = sinon.stub();
this.backoffFactory.returns(this.backoff);
callback();
},
tearDown: function (callback) {
callback();
},
"constructor's first argument should be a function": function (test) {
test.throws(function () {
const strategy = new FunctionCall(1, [], function () {});
return strategy;
}, /first argument to be a function/);
test.done();
},
"constructor's last argument should be a function": function (test) {
test.throws(function () {
const strategy = new FunctionCall(function () {}, [], 3);
return strategy;
}, /Expected third \(last\) arugment to be a function/);
test.done();
},
'isPending should return false once the call is started': function (test) {
this.wrappedFn
.onFirstCall().yields(new Error())
.onSecondCall().yields(null, 'Success!');
var call = new FunctionCall(this.wrappedFn, [], this.callback);
test.ok(call.isPending());
call.start(this.backoffFactory);
test.ok(!call.isPending());
this.backoff.emit('ready');
test.ok(!call.isPending());
test.done();
},
'isRunning should return true when call is in progress': function (test) {
this.wrappedFn
.onFirstCall().yields(new Error())
.onSecondCall().yields(null, 'Success!');
var call = new FunctionCall(this.wrappedFn, [], this.callback);
test.ok(!call.isRunning());
call.start(this.backoffFactory);
test.ok(call.isRunning());
this.backoff.emit('ready');
test.ok(!call.isRunning());
test.done();
},
'isCompleted should return true once the call completes': function (test) {
this.wrappedFn
.onFirstCall().yields(new Error())
.onSecondCall().yields(null, 'Success!');
var call = new FunctionCall(this.wrappedFn, [], this.callback);
test.ok(!call.isCompleted());
call.start(this.backoffFactory);
test.ok(!call.isCompleted());
this.backoff.emit('ready');
test.ok(call.isCompleted());
test.done();
},
'isAborted should return true once the call is aborted': function (test) {
this.wrappedFn
.onFirstCall().yields(new Error())
.onSecondCall().yields(null, 'Success!');
var call = new FunctionCall(this.wrappedFn, [], this.callback);
test.ok(!call.isAborted());
call.abort();
test.ok(call.isAborted());
test.done();
},
'setStrategy should overwrite the default strategy': function (test) {
var replacementStrategy = {};
var call = new FunctionCall(this.wrappedFn, [], this.callback);
call.setStrategy(replacementStrategy);
call.start(this.backoffFactory);
test.ok(this.backoffFactory.calledWith(replacementStrategy),
'User defined strategy should be used to instantiate ' +
'the backoff instance.');
test.done();
},
'setStrategy should throw if the call is in progress': function (test) {
var call = new FunctionCall(this.wrappedFn, [], this.callback);
call.start(this.backoffFactory);
test.throws(function () {
call.setStrategy({});
}, /in progress/);
test.done();
},
'failAfter should not be set by default': function (test) {
var call = new FunctionCall(this.wrappedFn, [], this.callback);
call.start(this.backoffFactory);
test.equal(0, this.backoff.failAfter.callCount);
test.done();
},
'failAfter should be used as the maximum number of backoffs': function (test) {
var failAfterValue = 99;
var call = new FunctionCall(this.wrappedFn, [], this.callback);
call.failAfter(failAfterValue);
call.start(this.backoffFactory);
test.ok(this.backoff.failAfter.calledWith(failAfterValue),
'User defined maximum number of backoffs shoud be ' +
'used to configure the backoff instance.');
test.done();
},
'failAfter should throw if the call is in progress': function (test) {
var call = new FunctionCall(this.wrappedFn, [], this.callback);
call.start(this.backoffFactory);
test.throws(function () {
call.failAfter(1234);
}, /in progress/);
test.done();
},
"start shouldn't allow overlapping invocation": function (test) {
var call = new FunctionCall(this.wrappedFn, [], this.callback);
var backoffFactory = this.backoffFactory;
call.start(backoffFactory);
test.throws(function () {
call.start(backoffFactory);
}, /already started/);
test.done();
},
"start shouldn't allow invocation of aborted call": function (test) {
var call = new FunctionCall(this.wrappedFn, [], this.callback);
var backoffFactory = this.backoffFactory;
call.abort();
test.throws(function () {
call.start(backoffFactory);
}, /aborted/);
test.done();
},
'call should forward its arguments to the wrapped function': function (test) {
var call = new FunctionCall(this.wrappedFn, [1, 2, 3], this.callback);
call.start(this.backoffFactory);
test.ok(this.wrappedFn.calledWith(1, 2, 3));
test.done();
},
'call should complete when the wrapped function succeeds': function (test) {
var call = new FunctionCall(this.wrappedFn, [1, 2, 3], this.callback);
this.wrappedFn
.onCall(0).yields(new Error())
.onCall(1).yields(new Error())
.onCall(2).yields(new Error())
.onCall(3).yields(null, 'Success!');
call.start(this.backoffFactory);
for (var i = 0; i < 2; i++) {
this.backoff.emit('ready');
}
test.equals(this.callback.callCount, 0);
this.backoff.emit('ready');
test.ok(this.callback.calledWith(null, 'Success!'));
test.ok(this.wrappedFn.alwaysCalledWith(1, 2, 3));
test.done();
},
'call should fail when the backoff limit is reached': function (test) {
var call = new FunctionCall(this.wrappedFn, [1, 2, 3], this.callback);
var error = new Error();
this.wrappedFn.yields(error);
call.start(this.backoffFactory);
for (var i = 0; i < 3; i++) {
this.backoff.emit('ready');
}
test.equals(this.callback.callCount, 0);
this.backoff.emit('fail');
test.ok(this.callback.calledWith(error));
test.ok(this.wrappedFn.alwaysCalledWith(1, 2, 3));
test.done();
},
'call should fail when the retry predicate returns false': function (test) {
var call = new FunctionCall(this.wrappedFn, [1, 2, 3], this.callback);
call.retryIf(function (err) { return err.retriable; });
var retriableError = new Error();
retriableError.retriable = true;
var fatalError = new Error();
fatalError.retriable = false;
this.wrappedFn
.onCall(0).yields(retriableError)
.onCall(1).yields(retriableError)
.onCall(2).yields(fatalError);
call.start(this.backoffFactory);
for (var i = 0; i < 2; i++) {
this.backoff.emit('ready');
}
test.equals(this.callback.callCount, 1);
test.ok(this.callback.calledWith(fatalError));
test.ok(this.wrappedFn.alwaysCalledWith(1, 2, 3));
test.done();
},
"wrapped function's callback shouldn't be called after abort": function (test) {
var call = new FunctionCall(function (callback) {
call.abort(); // Abort in middle of wrapped function's execution.
callback(null, 'ok');
}, [], this.callback);
call.start(this.backoffFactory);
test.equals(this.callback.callCount, 1,
'Wrapped function\'s callback shouldn\'t be called after abort.');
test.ok(this.callback.calledWithMatch(sinon.match(function (err) {
return !!err.message.match(/Backoff aborted/);
}, 'abort error')));
test.done();
},
'abort event is emitted once when abort is called': function (test) {
var call = new FunctionCall(this.wrappedFn, [], this.callback);
this.wrappedFn.yields(new Error());
var callEventSpy = sinon.spy();
call.on('abort', callEventSpy);
call.start(this.backoffFactory);
call.abort();
call.abort();
call.abort();
test.equals(callEventSpy.callCount, 1);
test.done();
},
'getLastResult should return the last intermediary result': function (test) {
var call = new FunctionCall(this.wrappedFn, [], this.callback);
this.wrappedFn.yields(1);
call.start(this.backoffFactory);
for (var i = 2; i < 5; i++) {
this.wrappedFn.yields(i);
this.backoff.emit('ready');
test.deepEqual([i], call.getLastResult());
}
this.wrappedFn.yields(null);
this.backoff.emit('ready');
test.deepEqual([null], call.getLastResult());
test.done();
},
'getNumRetries should return the number of retries': function (test) {
var call = new FunctionCall(this.wrappedFn, [], this.callback);
this.wrappedFn.yields(1);
call.start(this.backoffFactory);
// The inital call doesn't count as a retry.
test.equals(0, call.getNumRetries());
for (var i = 2; i < 5; i++) {
this.wrappedFn.yields(i);
this.backoff.emit('ready');
test.equals(i - 1, call.getNumRetries());
}
this.wrappedFn.yields(null);
this.backoff.emit('ready');
test.equals(4, call.getNumRetries());
test.done();
},
"wrapped function's errors should be propagated": function (test) {
var call = new FunctionCall(this.wrappedFn, [1, 2, 3], this.callback);
this.wrappedFn.throws(new Error());
test.throws(function () {
call.start(this.backoffFactory);
}, Error);
test.done();
},
"wrapped callback's errors should be propagated": function (test) {
var call = new FunctionCall(this.wrappedFn, [1, 2, 3], this.callback);
this.wrappedFn.yields(null, 'Success!');
this.callback.throws(new Error());
test.throws(function () {
call.start(this.backoffFactory);
}, Error);
test.done();
},
'call event should be emitted when wrapped function gets called': function (test) {
this.wrappedFn.yields(1);
var callEventSpy = sinon.spy();
var call = new FunctionCall(this.wrappedFn, [1, 'two'], this.callback);
call.on('call', callEventSpy);
call.start(this.backoffFactory);
for (var i = 1; i < 5; i++) {
this.backoff.emit('ready');
}
test.equal(5, callEventSpy.callCount,
'The call event should have been emitted 5 times.');
test.deepEqual([1, 'two'], callEventSpy.getCall(0).args,
'The call event should carry function\'s args.');
test.done();
},
'callback event should be emitted when callback is called': function (test) {
var call = new FunctionCall(this.wrappedFn, [1, 'two'], this.callback);
var callbackSpy = sinon.spy();
call.on('callback', callbackSpy);
this.wrappedFn.yields('error');
call.start(this.backoffFactory);
this.wrappedFn.yields(null, 'done');
this.backoff.emit('ready');
test.equal(2, callbackSpy.callCount,
'Callback event should have been emitted 2 times.');
test.deepEqual(['error'], callbackSpy.firstCall.args,
'First callback event should carry first call\'s results.');
test.deepEqual([null, 'done'], callbackSpy.secondCall.args,
'Second callback event should carry second call\'s results.');
test.done();
},
'backoff event should be emitted on backoff start': function (test) {
var err = new Error('backoff event error');
var call = new FunctionCall(this.wrappedFn, [1, 'two'], this.callback);
var backoffSpy = sinon.spy();
call.on('backoff', backoffSpy);
this.wrappedFn.yields(err);
call.start(this.backoffFactory);
this.backoff.emit('backoff', 3, 1234, err);
test.ok(this.backoff.backoff.calledWith(err),
'The backoff instance should have been called with the error.');
test.equal(1, backoffSpy.callCount,
'Backoff event should have been emitted 1 time.');
test.deepEqual([3, 1234, err], backoffSpy.firstCall.args,
'Backoff event should carry the backoff number, delay and error.');
test.done();
}
};