diff --git a/Gruntfile.coffee b/Gruntfile.coffee index 3c09e44..ce7b021 100644 --- a/Gruntfile.coffee +++ b/Gruntfile.coffee @@ -69,7 +69,7 @@ module.exports = (grunt) -> stderr: true failOnError: true coverage: - command: 'istanbul cover jasmine-node --captureExceptions test && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage' + command: 'istanbul cover jasmine-node --forceexit --captureExceptions test && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage' jasmine: command: 'jasmine-node --verbose --captureExceptions test' publish: diff --git a/README.md b/README.md index ffc58f0..aa158ae 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ This module shares helpers among all [SPHERE.IO](http://sphere.io/) Node.js comp * [TaskQueue](#taskqueue) * [Sftp](#sftp) * [ProjectCredentialsConfig](#projectcredentialsconfig) + * [Repeater](#repeater) * [ElasticIo](#elasticio) * [Mixins](#mixins) * [Qutils](#qutils) @@ -55,6 +56,7 @@ Currently following helpers are provided by `SphereUtils`: - `Sftp` - `ProjectCredentialsConfig` - `ElasticIo` +- `Repeater` #### Logger Logging is supported by the lightweight JSON logging module called [Bunyan](https://github.com/trentm/node-bunyan). @@ -194,6 +196,31 @@ Following files are used to store the credentials and would be searched (descend #### ElasticIo _(Coming soon)_ +#### Repeater + + Repeater is designed to repeat some arbitrary function unless the execution of this function does not throw any errors + + Options: + + * **attempts** - Int - how many times execution of the function should be repeated until repeater will give up (default 10) + * **timeout** - Long - the delay between attempts + * **timeoutType** - String - The type of the timeout: + * `'constant'` - always the same timeout + * `'variable'` - timeout grows with the attempts count (it also contains random component) + +Example: + +```coffeescript +repeater = new Repeater {attempts: 10} + +repeater.execute + recoverableError: (e) -> e instanceof ErrorStatusCode and e.code is 409 + task: -> + console.info("get some stuff..") + console.info("update some another things...") + Q("Done") +``` + ### Mixins Currently following mixins are provided by `SphereUtils`: diff --git a/src/coffee/helpers/repeater.coffee b/src/coffee/helpers/repeater.coffee new file mode 100644 index 0000000..63c3829 --- /dev/null +++ b/src/coffee/helpers/repeater.coffee @@ -0,0 +1,64 @@ +Q = require 'q' +_ = require 'underscore' + +###* + * Repeater is designed to repeat some arbitrary function unless the execution of this function does not throw any errors + * + * Options: + * attempts - Int - how many times execution of the function should be repeated until repeater will give up (default 10) + * timeout - Long - the delay between attempts + * timeoutType - String - The type of the timeout: + * 'constant' - always the same timeout + * 'variable' - timeout grows with the attempts count (it also contains random component) +### +class Repeater + constructor: (options = {}) -> + @_attempts = if options.attempts? then options.attempts else 10 + @_timeout = if options.timeout? then options.timeout else 100 + @_timeoutType = options.timeoutType or 'variable' + + ###* + * Executes arbitrary function + * + * Options: + * task - () => Promise[Any] - the task that should be executed + * recoverableError - Error => Boolean - function that decides, whether an error can be recovered by repeating the task execution + ### + execute: (options) -> + throw new Error '`task` function is undefined' unless _.isFunction(options.task) + throw new Error '`recoverableError` function is undefined' unless _.isFunction(options.recoverableError) + + d = Q.defer() + + @_repeat(@_attempts, options, d, null) + + d.promise + + _repeat: (remainingAttempts, options, defer, lastError) -> + {task, recoverableError} = options + + if remainingAttempts is 0 + defer.reject new Error("Unsuccessful after #{@_attempts} attempts. Cause: #{lastError.stack}") + else + task() + .then (res) -> + defer.resolve res + .fail (e) => + if recoverableError(e) + Q.delay @_calculateDelay(remainingAttempts) + .then (i) => + @_repeat(remainingAttempts - 1, options, defer, e) + else + defer.reject e + .done() + + _calculateDelay: (attemptsLeft) -> + if @_timeoutType is 'constant' + @_timeout + else if @_timeoutType is 'variable' + tried = @_attempts - attemptsLeft - 1 + (@_timeout * tried) + _.random(50, @_timeout) + else + throw new Error("Unsupported timeout type: #{@_timeoutType}") + +exports.Repeater = Repeater diff --git a/src/coffee/main.coffee b/src/coffee/main.coffee index 67387b4..9f3bf43 100644 --- a/src/coffee/main.coffee +++ b/src/coffee/main.coffee @@ -3,6 +3,7 @@ exports.Logger = require './helpers/logger' exports.TaskQueue = require './helpers/task-queue' exports.Sftp = require './helpers/sftp' exports.ProjectCredentialsConfig = require './helpers/project-credentials-config' +exports.Repeater = require('./helpers/repeater').Repeater exports.ElasticIo = require './helpers/elasticio' # mixins diff --git a/src/spec/helpers/repeater.spec.coffee b/src/spec/helpers/repeater.spec.coffee new file mode 100644 index 0000000..268b17b --- /dev/null +++ b/src/spec/helpers/repeater.spec.coffee @@ -0,0 +1,70 @@ +Q = require 'q' +_ = require 'underscore' +_.mixin require('underscore.string').exports() + +{Repeater} = require '../../lib/helpers/repeater' + +describe 'Repeater', -> + it 'should repeat task until it returns some successful result', (done) -> + repeated = 0 + + new Repeater + attempts: 10 + timeout: 0 + .execute + recoverableError: (e) -> e.message is 'foo' + task: -> + repeated += 1 + + if repeated < 3 + Q.reject(new Error('foo')) + else + Q("Success") + .then (res) -> + expect(repeated).toEqual 3 + expect(res).toEqual "Success" + done() + .fail (error) -> + done(error) + + it 'should boubble up unrecoverable errors', (done) -> + repeated = 0 + + new Repeater + attempts: 10 + timeout: 0 + .execute + recoverableError: (e) -> e.message is 'foo' + task: -> + repeated += 1 + + if repeated < 3 + Q.reject(new Error('foo')) + else if repeated is 3 + Q.reject(new Error('baz')) + else + Q("Success") + .then (res) -> + done("Error was not produced.") + .fail (error) -> + expect(repeated).toEqual 3 + expect(error.message).toEqual "baz" + done() + + it 'should boubble up an error after provided number of attempts', (done) -> + repeated = 0 + + new Repeater + attempts: 3 + timeout: 0 + .execute + recoverableError: (e) -> e.message is 'foo' + task: -> + repeated += 1 + Q.reject(new Error('foo')) + .then (res) -> + done("Error was not produced.") + .fail (error) -> + expect(repeated).toEqual 3 + expect(_.startsWith(error.message, 'Unsuccessful after 3 attempts')).toBe true + done() \ No newline at end of file