Exponential backoff for promises

I'm sure you're all  familiar with both Promises and exponential backoff. If you're a regular visitor to this site, you'll know these are two topics I often write about. In this post, I'll combine the two, as exponential backoff should be able to handle promises as well as callbacks nowadays.

The waiting game problem

If you are using any kind of service with rate limiting, or some other kind of reason that you might need to wait a bit and retry, you are probably already using exponential backoff. However if you are also using promises, then the approach needs to be a little different.

You can of course use this with any API but for this example, I'll use the Node/JavaScript version of the Ephemeral Exchange API to handle a rejection request for an update intention. 

You might get one of these if you state an intention to update a cache item, but someone else already already has one.  For these examples, I'll use ES6 for brevity.

Here's a  normal read with an intention. This will work fine unless someone else is also intending to update, in which case we'll get back an error 429 (locked)

  efx.read(data.id , keys.writer, {intention:"update"})
  .then ((result)=>console.log(result.data));

We can wrap all that in exponential backoff (I'll go into the code for that later on), which eventually keeps trying and returns a promise that resolves when a good result is achieved (or we give up).

   efx.expBackoff( ()=>efx.read(data.id , keys.writer, {intention:"update"}),
    (lastResult)=>lastResult.data.code===423)
    .then ((result)=>console.log(result.data))
    .catch ((err)=>console.log(err.data));

The arguments are expBackoff ( action  , doRetry , options), so in the example above
  • action - is a promise which is resolved when efx.read is executed
  • doRetry - is a function that should return true is a re-attempt is required , or false if no retry is required. Here I only want to retry if I detect a 423 error.
  • options - we'll come to them later
Eventually this will resolve (when the other person who is locking this record for update has finished), or it will reject when I've tried enough times or there is an error. The amount of time it waits between attempts is exponentially increasing according to the normal exponential backoff algorithm.

But we're missing a trick here, because along with a 429 code, we also get a hint of the maximum amount of time that this item will be locked for, so we could do with a mechanism to adjust the next wait time if it could be reduced (because the lock on the item will expire).  The third argument to backoff are various options, one of which is a custom function to tweak the next wait time. It receives various values which allow you to customise the wait time, but the key one here is the result, which contains a property (intentExpires). If that is less than the proposed next wait time, I'll use that instead.

  efx.expBackoff(()=>efx.read(data.id , keys.updater, {intention:"update"}),
    (lastResult)=>lastResult.data.code===423,{
      setWaitTime:(waitTime, passes, result,proposed) => { 
        // take the minimum of exp time and remaining waiting time on current intent
        return Math.min(proposed, ((result && result.data && result.data.intentExpires) || 0) * 1000);
      }})
    .then ((result)=>console.log(result.data))
    .catch ((err)=>console.log(err.data));  

And that's it - no more worries about what to do when it's locked, or wasting a lot of time waiting unnecessarily.

The code

These functions are handily available in the ephemeral exchange API namespace from v1.1 , so no extra libraries are needed - or you can just copy them into your own code from below.
 
/**
   * check a thing is a promise and make it so if not
   * @param {*} thing the thing
   * @param {Promise}
   */
  ns.promify = function(thing) {

    // is it already a promise
    var isPromise = !!thing &&
      (typeof thing === 'object' || typeof thing === 'function') &&
      typeof thing.then === 'function';

    // is is a function
    var isFunction = !isPromise && !!thing && typeof thing === 'function';

    // create a promise of it .. will also make a promise out of a value
    return Promise.resolve(isFunction ? thing() : thing);
  };

  /**
   * a handly timer
   * @param {*} [packet] something to pass through when time is up
   * @param {number} ms number of milliseconds to wait
   * @return {Promise} when over
   */
  ns.handyTimer = function(ms, packet) {
    // wait some time then resolve
    return new Promise(function(resolve, reject) {
      setTimeout(function() {
        resolve(packet);
      }, ms);
    });
  };

  /**
   * expbackoff
   * @param {function | Promise} action what to do 
   * @param {function} doRetry whether to retry
   * @param {object} [options]
   * @param {number} [options.maxPasses=5] how many times to try
   * @param {number} [options.waitTime=500] how long to wait on failure
   * @param {number} [options.passes=0] how many times we've gone
   * @param {function} [options.setWaitTime=function(waitTime, passes,result,proposed) { ... return exptime.. }]
   * @return {Promise} when its all over
   */
  ns.expBackoff = function(action, doRetry, options) {

    options = options || {};


    // this is the default waittime
    function defaultWaitTime (waitTime, passes, result) {
      return Math.pow(2, passes) * waitTime + Math.round(Math.random() * 73);
    }

    // default calculation can be bypassed with a custom function
    var setWaitTime =  function(waitTime, passes, result ,proposed) {
      return options.setWaitTime ? options.setWaitTime (waitTime, passes, result,proposed) : 0;
    };
    
    // the defaults
    var waitTime = options.waitTime || 500;
    var passes = options.passes || 0;
    var maxPasses = options.maxPasses || 6;

    // keep most recent result here
    var lastResult;

    // the result will actually be a promise
    // resolves, or rejects if there's an uncaught failure or we run out of attempts
    return new Promise(function(resolve, reject) {

      // start
      worker(waitTime);

      // recursive 
      function worker(expTime) {

        // give up
        if (passes >= maxPasses) {

          // reject with last known result
          reject(lastResult);
        }
        // we still have some remaining attempts

        else {

          // call the action with the previous result as argument
          // turning it into a promise.resolve will handle both functions and promises
          ns.promify(action)
            .then(function(result) {
              // store what happened for later
              lastResult = result;

              // pass the result to the retry checker and wait a bit and go again if required
              if (doRetry(lastResult, passes++)) {
                return ns.handyTimer(expTime)
                  .then(function() {
                    var proposedWaitTime = defaultWaitTime(waitTime , passes , result );
                    worker(setWaitTime(waitTime, passes, lastResult,proposedWaitTime) || proposedWaitTime);
                  });
              }
              else {
                // finally
                resolve(lastResult);
              }
            });

        }
      }

    });
  };


You want to learn Google Apps Script?

Learning Apps Script, (and transitioning from VBA) are covered comprehensively in my my book, Going Gas - from VBA to Apps script, available All formats are available now from O'Reilly,Amazon and all good bookshops. You can also read a preview on O'Reilly

If you prefer Video style learning I also have two courses available. also published by O'Reilly.
Google Apps Script for Developers and Google Apps Script for Beginners.






For more like this, see Google Apps Scripts snippets. Why not join our forumfollow the blog or follow me on twitter to ensure you get updates when they are available. 



Comments