index.js 3.5 KB
'use strict';

var Promise = require('any-promise');
var util = require('util');
var format = util.format;

function TimeoutError(message, err) {
  Error.call(this);
  Error.captureStackTrace(this, TimeoutError);
  this.name = 'TimeoutError';
  this.message = message;
  this.previous = err;
}

util.inherits(TimeoutError, Error);

function matches(match, err) {
  if (match === true) return true;
  if (typeof match === 'function') {
    try {
      if (err instanceof match) return true;
    } catch (_) {
      return !!match(err);
    }
  }
  if (match === err.toString()) return true;
  if (match === err.message) return true;
  return match instanceof RegExp
    && (match.test(err.message) || match.test(err.toString()));
}

module.exports = function retryAsPromised(callback, options) {
  if (!callback || !options) {
    throw new Error(
      'retry-as-promised must be passed a callback and a options set or a number'
    );
  }

  if (typeof options === 'number') {
    options = {
      max: options
    };
  }

  // Super cheap clone
  options = {
    $current: options.$current || 1,
    max: options.max,
    timeout: options.timeout || undefined,
    match: options.match || [],
    backoffBase: options.backoffBase === undefined ? 100 : options.backoffBase,
    backoffExponent: options.backoffExponent || 1.1,
    report: options.report || function () {},
    name: options.name || callback.name || 'unknown'
  };

  if (!Array.isArray(options.match)) options.match = [options.match];
  options.report('Trying ' + options.name + ' #' + options.$current + ' at ' + new Date().toLocaleTimeString(), options);

  return new Promise(function(resolve, reject) {
    var timeout, backoffTimeout, lastError;

    if (options.timeout) {
      timeout = setTimeout(function() {
        if (backoffTimeout) clearTimeout(backoffTimeout);
        reject(new TimeoutError(options.name + ' timed out', lastError));
      }, options.timeout);
    }

    Promise.resolve(callback({ current: options.$current }))
      .then(resolve)
      .then(function() {
        if (timeout) clearTimeout(timeout);
        if (backoffTimeout) clearTimeout(backoffTimeout);
      })
      .catch(function(err) {
        if (timeout) clearTimeout(timeout);
        if (backoffTimeout) clearTimeout(backoffTimeout);

        lastError = err;
        options.report((err && err.toString()) || err, options);

        // Should not retry if max has been reached
        var shouldRetry = options.$current < options.max;
        if (!shouldRetry) return reject(err);
        shouldRetry = options.match.length === 0 || options.match.some(function (match) {
          return matches(match, err)
        });
        if (!shouldRetry) return reject(err);

        var retryDelay = Math.pow(
          options.backoffBase,
          Math.pow(options.backoffExponent, options.$current - 1)
        );

        // Do some accounting
        options.$current++;
        options.report(format('Retrying %s (%s)', options.name, options.$current), options);

        if (retryDelay) {
          // Use backoff function to ease retry rate
          options.report(format('Delaying retry of %s by %s', options.name, retryDelay), options);
          backoffTimeout = setTimeout(function() {
            retryAsPromised(callback, options)
              .then(resolve)
              .catch(reject);
          }, retryDelay);
        } else {
          retryAsPromised(callback, options)
            .then(resolve)
            .catch(reject);
        }
      });
  });
};

module.exports.TimeoutError = TimeoutError;