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);
                  }
                });
    
            }
          }
    
        });
    	};
    For more like this see Google Apps Scripts Snippets
    Why not join our forum, follow the blog or follow me on twitter to ensure you get updates when they are available.