├── .npmrc ├── .gitignore ├── lib ├── constant.js ├── logarithmicProgression.js ├── linear.js ├── simpleExponentialBackoff.js ├── exponentialBackoff.js ├── retryAlgorithm.js └── Stubborn.js ├── CHANGELOG.md ├── index.js ├── package.json ├── README.md └── test └── Stubborn.test.js /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.log 3 | -------------------------------------------------------------------------------- /lib/constant.js: -------------------------------------------------------------------------------- 1 | module.exports = function (c) { 2 | 3 | return function constant(attempts) { 4 | return c; 5 | }; 6 | }; -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | ## 1.0.0 3 | * add retry algorithms 4 | 5 | ## 0.0.1 6 | 7 | * Correct spelling errors 8 | 9 | ## 0.0.0 10 | 11 | * Release 12 | -------------------------------------------------------------------------------- /lib/logarithmicProgression.js: -------------------------------------------------------------------------------- 1 | /* 2 | log b (x) + 1 3 | */ 4 | module.exports = function (base) { 5 | if (base === undefined) base = 2; 6 | 7 | return function(retries) { 8 | return Math.floor( Math.log(retries) / Math.log(base) ) + 1; 9 | }; 10 | }; -------------------------------------------------------------------------------- /lib/linear.js: -------------------------------------------------------------------------------- 1 | /* 2 | ax + b linear function with defaults 3 | */ 4 | module.exports = function (a, b) { 5 | 6 | if (a === undefined) a = 1; 7 | if (b === undefined) b = 0; 8 | 9 | return function linear(attempts) { 10 | return a * attempts + b; 11 | }; 12 | }; -------------------------------------------------------------------------------- /lib/simpleExponentialBackoff.js: -------------------------------------------------------------------------------- 1 | /* 2 | a simple retries^exponent backoff calculation 3 | */ 4 | module.exports = function (exponent) { 5 | 6 | if (exponent === undefined) exponent = 2; 7 | 8 | return function simpleExponentialBackoff(retries) { 9 | 10 | return Math.pow(exponent, retries); 11 | }; 12 | }; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/Stubborn.js'); 2 | module.exports.exponentialBackoff = require('./lib/exponentialBackoff.js'); 3 | module.exports.simpleExponentialBackoff = require('./lib/simpleExponentialBackoff.js'); 4 | module.exports.logarithmicProgression = require('./lib/exponentialBackoff.js'); 5 | module.exports.linear = require('./lib/linear.js'); 6 | module.exports.constant = require('./lib/constant.js'); 7 | module.exports.retryAlgorithm = require('./lib/retryAlgorithm.js') -------------------------------------------------------------------------------- /lib/exponentialBackoff.js: -------------------------------------------------------------------------------- 1 | function random(start, end) { 2 | var range = end - start; 3 | return Math.floor((Math.random() * range) + start); 4 | } 5 | 6 | /* 7 | Exponential backoff: http://en.wikipedia.org/wiki/Exponential_backoff 8 | */ 9 | module.exports = function(exponent) { 10 | 11 | if (exponent === undefined) exponent = 2; 12 | 13 | return function exponentialBackoff(retries) { 14 | 15 | // wait anywhere between zero to 2^retries inclusive (hence +1) 16 | return random(0, Math.pow(exponent, retries) + 1); 17 | }; 18 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stubborn", 3 | "version": "1.2.5", 4 | "description": "Retry engine", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/grudzinski/stubborn.git" 12 | }, 13 | "keywords": [ 14 | "retry" 15 | ], 16 | "author": "Roman Grudzinski", 17 | "license": "ISC", 18 | "bugs": { 19 | "url": "https://github.com/grudzinski/stubborn/issues" 20 | }, 21 | "homepage": "https://github.com/grudzinski/stubborn", 22 | "dependencies": { 23 | "debug": "^4.1.1", 24 | "lodash": "^4.17.11" 25 | }, 26 | "devDependencies": { 27 | "chai": "^1.9.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/retryAlgorithm.js: -------------------------------------------------------------------------------- 1 | var exponentialBackoff = require('./exponentialBackoff.js'); 2 | var simpleExponentialBackoff = require('./simpleExponentialBackoff.js'); 3 | var logarithmicProgression = require('./exponentialBackoff.js'); 4 | var linear = require('./linear.js'); 5 | var constant = require('./constant.js'); 6 | 7 | module.exports = function(name) { 8 | if (name === 'exponentialBackoff') 9 | return exponentialBackoff; 10 | 11 | if (name === 'simpleExponentialBackoff') 12 | return simpleExponentialBackoff; 13 | 14 | if (name === 'linear') 15 | return linear; 16 | 17 | if (name === 'logarithmicProgression') 18 | return logarithmicProgression; 19 | 20 | if (name === 'constant') 21 | return constant; 22 | 23 | throw new Error('unknown retry algorithm: ' + name) 24 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Stubborn 2 | 3 | A retry engine 4 | 5 | ### Install 6 | ```sh 7 | npm install stubborn 8 | ``` 9 | 10 | ### Example 11 | ```js 12 | var Stubborn = require('stubborn'); 13 | 14 | var options = { 15 | maxAttempts: 5, 16 | delay: 1000 17 | }; 18 | 19 | var stubborn = new Stubborn(task, options, callback); 20 | 21 | stubborn.on('attemptError', onAttemptError); 22 | 23 | stubborn.run(); 24 | 25 | function task(callback) { 26 | if (Math.random() > 0.2) { 27 | callback('Task error'); 28 | } else { 29 | callback(null, 'Task result'); 30 | } 31 | } 32 | 33 | function callback(err, result) { 34 | if (err) { 35 | console.error(err); 36 | return; 37 | } 38 | console.log(result); 39 | } 40 | 41 | function onAttemptError(err) { 42 | console.error(err); 43 | } 44 | 45 | ``` 46 | ### pluggable retry algorithm 47 | All retry algorithms need the current number of attempts as input. As output, they are expected to produce a number that will be used as a factor of the delay. 48 | ```js 49 | var Stubborn = require('stubborn'); 50 | 51 | var options = { 52 | maxAttempts: 5, 53 | delay: 1000, 54 | retryAlgorithm: Stubborn.exponentialBackoff() 55 | }; 56 | 57 | var stubborn = new Stubborn(task, options, callback); 58 | 59 | ``` 60 | #### implement your own: 61 | ```js 62 | 63 | var options = { 64 | maxAttempts: 5, 65 | delay: 1000, 66 | retryAlgorithm: function(attempts) { 67 | // delay next execution in options.delay * attempts * 2 68 | // thus in attempt #2 we'll have 1000ms * 2 * 2 = 4 seconds delay 69 | return attempts * 2 70 | } 71 | }; 72 | ``` 73 | #### out of the box algorithms: 74 | ``` 75 | var Stubborn = require('stubborn'); 76 | 77 | var algo1 = Stubborn.exponentialBackoff(2) // classic http://en.wikipedia.org/wiki/Exponential_backoff 78 | var algo2 = Stubborn.simpleExponentialBackoff(2) // same as the above only without the random element 79 | var algo3 = Stubborn.logarithmicProgression(2) // logarithmic progression 80 | var algo4 = Stubborn.linear(1, 0) // ax+b 81 | var algo5 = Stubborm.constant(1) // constant / fixed progression 82 | ``` 83 | #### configure using names instead of functions 84 | ```js 85 | var options = { 86 | retryAlgorithm: 'linear', 87 | retryAlgorithmArgs: [ 1, 0 ] 88 | } 89 | ``` 90 | ### Methods 91 | * ```run``` starts specified task, call it only once 92 | * ```cancel``` stops retries 93 | 94 | ### Events 95 | * ```run``` 96 | * ```onAttemptError``` 97 | * ```schedule``` 98 | -------------------------------------------------------------------------------- /lib/Stubborn.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var debug = require('debug'); 3 | var util = require('util'); 4 | var constant = require('./constant') 5 | var EventEmitter = require('events').EventEmitter; 6 | var retryAlgorithm = require('./retryAlgorithm.js'); 7 | util.inherits(Stubborn, EventEmitter); 8 | 9 | function Stubborn(task, options, callback) { 10 | EventEmitter.call(this); 11 | this._debug = debug('Stubborn'); 12 | this._setTimeout = setTimeout; 13 | this._task = task; 14 | if (_.isFunction(options)) { 15 | callback = options; 16 | options = {}; 17 | } 18 | var maxAttempts = options.maxAttempts; 19 | this._maxAttempts = _.isUndefined(maxAttempts) ? 10 : maxAttempts; 20 | var delay = options.delay; 21 | this._delay = _.isUndefined(delay) ? 100 : delay; 22 | 23 | this._delayProgression = options.delayProgression || options.retryAlgorithm 24 | 25 | if (typeof (this._delayProgression) === 'string') { 26 | this._delayProgression = retryAlgorithm(this._delayProgression).apply(null, options.retryAlgorithmArgs || []) 27 | } 28 | 29 | if (!this._delayProgression) { 30 | this._delayProgression = constant(1); 31 | } 32 | 33 | this._callback = callback; 34 | this._attempt = 0; 35 | this._canceled = false; 36 | this._rerunBound = _.bind(this._rerun, this); 37 | this._onTaskExecutedBound = _.bind(this._onTaskExecuted, this); 38 | } 39 | 40 | var p = Stubborn.prototype; 41 | 42 | p.run = function() { 43 | this._debug('run'); 44 | var attempt = this._attempt; 45 | // if (attempt !== 0) { 46 | // throw new Error('Already running'); 47 | // } 48 | this.emit('run', attempt); 49 | this._attempt = attempt + 1; 50 | try { 51 | var onTaskExecuted = _.once(this._onTaskExecutedBound); 52 | this._task(onTaskExecuted); 53 | } catch(e) { 54 | this._onTaskExecuted(e); 55 | } 56 | }; 57 | 58 | p.cancel = function() { 59 | this._debug('cancel'); 60 | this._canceled = true; 61 | }; 62 | 63 | p._rerun = function() { 64 | this._debug('_rerun'); 65 | this.run(); 66 | }; 67 | 68 | p._onTaskExecuted = function(err) { 69 | this._debug('_onTaskExecuted'); 70 | if (err === undefined || err === null) { 71 | this._callback.apply(null, arguments); 72 | return; 73 | } 74 | this.emit('attemptError', err); 75 | var attempt = this._attempt; 76 | if (this._canceled || attempt >= this._maxAttempts) { 77 | this._callback.apply(null, arguments); 78 | return; 79 | } 80 | var delay = this._delay; 81 | 82 | var factor = this._delayProgression(attempt); 83 | delay = delay * factor; 84 | 85 | this.emit('schedule', delay, attempt); 86 | this._setTimeout(this._rerunBound, delay); 87 | }; 88 | 89 | delete p; 90 | 91 | module.exports = Stubborn; 92 | -------------------------------------------------------------------------------- /test/Stubborn.test.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var chai = require('chai'); 3 | var events = require('events'); 4 | var constant = require('../lib/constant.js') 5 | 6 | var assert = chai.assert; 7 | 8 | var EventEmitter = events.EventEmitter; 9 | var Stubborn = require('../lib/Stubborn.js'); 10 | 11 | describe('Stubbon', function() { 12 | 13 | it('constructs with options', function() { 14 | 15 | var testOptions = { 16 | maxAttempts: 'testMaxAttempts', 17 | delay: 'testDelay' 18 | }; 19 | 20 | var stubbon = new Stubborn('testTask', testOptions, 'testCallback'); 21 | 22 | assert.instanceOf(stubbon, EventEmitter); 23 | assert.strictEqual(stubbon._setTimeout, setTimeout); 24 | assert.strictEqual(stubbon._task, 'testTask'); 25 | assert.strictEqual(stubbon._callback, 'testCallback'); 26 | assert.strictEqual(stubbon._maxAttempts, 'testMaxAttempts'); 27 | assert.strictEqual(stubbon._delay, 'testDelay'); 28 | assert.strictEqual(stubbon._delayProgression.name, 'constant'); 29 | assert.strictEqual(stubbon._attempt, 0); 30 | assert.isFunction(stubbon._rerunBound); 31 | assert.isFunction(stubbon._onTaskExecutedBound); 32 | assert.isFalse(stubbon._canceled); 33 | 34 | }); 35 | 36 | it('constructs without options', function() { 37 | 38 | var testCallback = function() {}; 39 | 40 | var stubbon = new Stubborn('testTask', testCallback); 41 | 42 | assert.instanceOf(stubbon, EventEmitter); 43 | assert.strictEqual(stubbon._setTimeout, setTimeout); 44 | assert.strictEqual(stubbon._task, 'testTask'); 45 | assert.strictEqual(stubbon._callback, testCallback); 46 | assert.strictEqual(stubbon._maxAttempts, 10); 47 | assert.strictEqual(stubbon._delay, 100); 48 | assert.strictEqual(stubbon._delayProgression.name, 'constant'); 49 | assert.strictEqual(stubbon._attempt, 0); 50 | assert.isFunction(stubbon._rerunBound); 51 | assert.isFunction(stubbon._onTaskExecutedBound); 52 | 53 | }); 54 | 55 | it('override default retry algorithm by providing a function in the options hash', function() { 56 | 57 | var myRetryAlgorithm = function () {} 58 | 59 | var testCallback = function() {}; 60 | 61 | var stubborn = new Stubborn('testTask', { retryAlgorithm: myRetryAlgorithm }, testCallback); 62 | 63 | assert.strictEqual(stubborn._delayProgression, myRetryAlgorithm) 64 | }); 65 | 66 | it('override default retry algorithm using a string name of one of the out of box algorithms', function() { 67 | 68 | var testCallback = function() {}; 69 | 70 | var stubborn = new Stubborn('testTask', { retryAlgorithm: 'linear' }, testCallback); 71 | 72 | assert.strictEqual(stubborn._delayProgression.name, 'linear') 73 | }); 74 | 75 | it.skip('run throw exception when run already called', function() { 76 | 77 | var mockDebugCallCount = 0; 78 | 79 | var mock = { 80 | 81 | _debug: function(message) { 82 | assert.strictEqual(message, 'run'); 83 | mockDebugCallCount++; 84 | }, 85 | 86 | _attempt: 1 87 | 88 | }; 89 | 90 | try { 91 | Stubborn.prototype.run.call(mock); 92 | assert.fail(); 93 | } catch (e) { 94 | assert.strictEqual(e.message, 'Already running'); 95 | } 96 | 97 | assert.strictEqual(mockDebugCallCount, 1); 98 | 99 | }); 100 | 101 | it('run when task does not throw an exception', function() { 102 | 103 | var mockTaskCallCount = 0; 104 | var mockEmitCallCount = 0; 105 | var mockOnTaskExecutedBoundCallCount = 0; 106 | 107 | var mock = { 108 | 109 | _debug: function() { 110 | 111 | }, 112 | 113 | _task: function(callback) { 114 | callback('testError'); 115 | mockTaskCallCount++; 116 | }, 117 | 118 | emit: function(type, attempt) { 119 | assert.strictEqual(type, 'run'); 120 | assert.strictEqual(attempt, 0); 121 | mockEmitCallCount++; 122 | }, 123 | 124 | _onTaskExecutedBound: function(err) { 125 | assert.strictEqual(err, 'testError'); 126 | mockOnTaskExecutedBoundCallCount++; 127 | }, 128 | 129 | _attempt: 0 130 | 131 | }; 132 | 133 | Stubborn.prototype.run.call(mock); 134 | 135 | assert.strictEqual(mock._attempt, 1); 136 | assert.strictEqual(mockTaskCallCount, 1); 137 | assert.strictEqual(mockEmitCallCount, 1); 138 | assert.strictEqual(mockOnTaskExecutedBoundCallCount, 1); 139 | 140 | }); 141 | 142 | it('run when task throws an exception', function() { 143 | 144 | var mockTaskCallCount = 0; 145 | var mockOnTaskExecutedCallCount = 0; 146 | var mockOnTaskExecutedBoundCallCount = 0; 147 | var mockEmitCallCount = 0; 148 | 149 | var mock = { 150 | 151 | _debug: function() { 152 | 153 | }, 154 | 155 | _task: function(callback) { 156 | callback('testError'); 157 | mockTaskCallCount++; 158 | throw new Error('Test exception'); 159 | }, 160 | 161 | emit: function(type, attempt) { 162 | assert.strictEqual(type, 'run'); 163 | assert.strictEqual(attempt, 0); 164 | mockEmitCallCount++; 165 | }, 166 | 167 | _onTaskExecutedBound: function(err) { 168 | assert.strictEqual(err, 'testError'); 169 | mockOnTaskExecutedBoundCallCount++; 170 | }, 171 | 172 | _onTaskExecuted: function(err) { 173 | assert(err instanceof Error); 174 | assert.strictEqual(err.message, 'Test exception'); 175 | mockOnTaskExecutedCallCount++; 176 | }, 177 | 178 | _attempt: 0 179 | 180 | }; 181 | 182 | Stubborn.prototype.run.call(mock); 183 | 184 | assert.strictEqual(mock._attempt, 1); 185 | assert.strictEqual(mockTaskCallCount, 1); 186 | assert.strictEqual(mockOnTaskExecutedCallCount, 1); 187 | assert.strictEqual(mockOnTaskExecutedBoundCallCount, 1); 188 | assert.strictEqual(mockEmitCallCount, 1); 189 | 190 | }); 191 | 192 | it('cancel', function() { 193 | 194 | var mockDebugCallCount = 0; 195 | 196 | var mock = { 197 | 198 | _debug: function(message) { 199 | assert.strictEqual(message, 'cancel'); 200 | mockDebugCallCount++; 201 | } 202 | 203 | }; 204 | 205 | Stubborn.prototype.cancel.call(mock); 206 | 207 | assert.isTrue(mock._canceled); 208 | assert.strictEqual(mockDebugCallCount, 1); 209 | 210 | }); 211 | 212 | it('_rerun', function() { 213 | 214 | var mockRunCallCount = 0; 215 | 216 | var mock = { 217 | 218 | _debug: function() { 219 | 220 | }, 221 | 222 | run: function() { 223 | mockRunCallCount++; 224 | } 225 | 226 | }; 227 | 228 | Stubborn.prototype._rerun.call(mock); 229 | 230 | assert.strictEqual(mockRunCallCount, 1); 231 | 232 | }); 233 | 234 | it('_onTaskExecuted without an error', function() { 235 | 236 | var mockCallbackCallCount = 0; 237 | 238 | var mock = { 239 | 240 | _debug: function() { 241 | 242 | }, 243 | 244 | _callback: function(err) { 245 | assert.deepEqual(_.toArray(arguments), [null, 'testArgA', 'testArgB']); 246 | mockCallbackCallCount++; 247 | } 248 | 249 | }; 250 | 251 | Stubborn.prototype._onTaskExecuted.call(mock, null, 'testArgA', 'testArgB'); 252 | 253 | assert.strictEqual(mockCallbackCallCount, 1); 254 | 255 | }); 256 | 257 | it('_onTaskExecuted with an error and max attempts reached', function() { 258 | 259 | var mockEmitCallCount = 0; 260 | var maxCallbackCallCount = 0; 261 | 262 | var mock = { 263 | 264 | _debug: function() { 265 | 266 | }, 267 | 268 | emit: function(type, error) { 269 | assert.strictEqual(type, 'attemptError') 270 | assert.strictEqual(error, 'testError'); 271 | mockEmitCallCount++; 272 | }, 273 | 274 | _attempt: 1, 275 | 276 | _maxAttempts: 1, 277 | 278 | _canceled: false, 279 | 280 | _callback: function(err) { 281 | assert.strictEqual(err, 'testError'); 282 | maxCallbackCallCount++; 283 | } 284 | }; 285 | 286 | Stubborn.prototype._onTaskExecuted.call(mock, 'testError'); 287 | 288 | assert.strictEqual(mockEmitCallCount, 1); 289 | assert.strictEqual(maxCallbackCallCount, 1); 290 | 291 | }); 292 | 293 | it('_onTaskExecuted with an error and it in canceled state', function() { 294 | 295 | var mockDebugCallCount = 0; 296 | var mockEmitCallCount = 0; 297 | var maxCallbackCallCount = 0; 298 | 299 | var mock = { 300 | 301 | _debug: function(message) { 302 | assert.strictEqual(message, '_onTaskExecuted'); 303 | mockDebugCallCount++; 304 | }, 305 | 306 | emit: function(type, error) { 307 | assert.strictEqual(type, 'attemptError') 308 | assert.strictEqual(error, 'testError'); 309 | mockEmitCallCount++; 310 | }, 311 | 312 | _attempt: 1, 313 | 314 | _maxAttempts: 5, 315 | 316 | _canceled: true, 317 | 318 | _callback: function(err) { 319 | assert.strictEqual(err, 'testError'); 320 | maxCallbackCallCount++; 321 | } 322 | }; 323 | 324 | Stubborn.prototype._onTaskExecuted.call(mock, 'testError'); 325 | 326 | assert.strictEqual(mockDebugCallCount, 1); 327 | assert.strictEqual(mockEmitCallCount, 1); 328 | assert.strictEqual(maxCallbackCallCount, 1); 329 | 330 | }); 331 | 332 | it('_onTaskExecuted with an error and max attempts is not reached', function() { 333 | 334 | var mockEmitCallCount = 0; 335 | var mockSetTimeoutCallCount = 0; 336 | 337 | var mock = { 338 | 339 | _delayProgression: constant(1), 340 | 341 | _debug: function() { 342 | 343 | }, 344 | 345 | emit: function(type, error) { 346 | if (mockEmitCallCount === 0) { 347 | assert.deepEqual(_.toArray(arguments), ['attemptError', 'testError']); 348 | } 349 | if (mockEmitCallCount === 1) { 350 | assert.deepEqual(_.toArray(arguments), ['schedule', 1000, 5]); 351 | } 352 | mockEmitCallCount++; 353 | }, 354 | 355 | _attempt: 5, 356 | 357 | _maxAttempts: 10, 358 | 359 | _delay: 1000, 360 | 361 | _rerunBound: 'testRerunBound', 362 | 363 | _setTimeout: function(callback, delay) { 364 | assert.strictEqual(callback, 'testRerunBound'); 365 | assert.strictEqual(delay, 1000); 366 | mockSetTimeoutCallCount++; 367 | } 368 | 369 | }; 370 | 371 | Stubborn.prototype._onTaskExecuted.call(mock, 'testError'); 372 | 373 | assert.strictEqual(mockEmitCallCount, 2); 374 | assert.strictEqual(mockSetTimeoutCallCount, 1); 375 | 376 | }); 377 | 378 | }); --------------------------------------------------------------------------------