« Back
in Node.js Salesforce Promises read.
Retry Refresh Token Pattern with Node Promises and Salesforce

Retry Refresh Token Pattern with Node Promises and Salesforce.

Recently I've been working a bit at Australia's most recognized Airline and had to interact with Salesforce. The problem with their implementation of OAuth2.0 is that no refresh token is issued for backend to backend services. Unfortunately they don't implement grant type client_credentials.

After working with a colleague, we came up with a very nice and clean pattern on how to do the following:

  1. Get an Access Token
  2. If the next call to Salesforce API receives 401 (unauthorized) then;
  3. Refresh the Token and Retry the call

The goal was to keep the code clean of too many nested promises and callbacks and to deal with concurrency. By dealing with concurrency I mean don't have too many users trying to refresh the token at the same time or waiting.

This app is particularly special because there is a Redis front end and it uses Salesforce as the source of truth. There is some logic in the app to do things with the cache before going to get the token and then again hitting the Salesforce API. So there was a real need to make it clean.

I'm going to abstract the code a bit so that it doesn't match exactly what's in the code base for these snippets.

Retry Util

First let's look at the retry.js file:

import logger from '../logger';

export function retryOnce (func, recoverFunc) {  
  return func()
    .catch( err => {
      logger.info(`Calling function failed with error: ${JSON.stringify(err)}, retrying once after recovery`);
      return recoverFunc(err).then(() => func());
    }); 
}

As you may have guessed, we're dealing with promises and ES6. This function takes the primary function func and executes it. It will return the original promise of func if it's successful so there's no need to use another then().

We're only interested when there is an error in the catch block, it will try to run the recoverFunc before attempting to run the original function again.

So let's take a look at what this test looks like:
retry.spec.js

import sinon from 'sinon';  
import { expect } from 'chai';  
import { retryOnce } from './retry';

describe('retry util', () => {

  it('should return error when recovery fails', () => {
    const funcSpy = sinon.spy(() => Promise.reject('error'));
    const recoverySpy = sinon.spy(() => Promise.reject('error'));

    return retryOnce(funcSpy, recoverySpy).catch(err => {
      expect(funcSpy).to.be.calledOnce;
      expect(recoverySpy).to.be.calledOnce;
      expect(recoverySpy).to.be.calledWith('error');
      expect(err).to.equal('error');
    });
  });

  it('should retry once and return success', () => {
    const funcStub = sinon.stub();
    funcStub.onCall(0).returns(Promise.reject('error'));
    funcStub.onCall(1).returns(Promise.resolve('success'));
    const recoverySpy = sinon.spy(() => Promise.resolve());

    return retryOnce(funcStub, recoverySpy).then(data => {
      expect(funcStub).to.be.calledTwice;
      expect(recoverySpy).to.be.calledOnce;
      expect(recoverySpy).to.be.calledWith('error');
      expect(data).to.equal('success');
    });
  });
});

The most important one is the second test where we issue a failure and then a success. So we should receive a success later.

Mocha, Sinon and Chai have been an absolute dream to work with. Proxyquire is a pretty good one too and they're all part of my tools of choice for Node. My favourite part is having a pre-commit hook that forces linting and tests to run before you can even git commit.

Salesforce OAuth Service

This is how we actually retrieve a token from Salesforce to use against its APIs.
There is some pretty interesting bits in here:
salesforceOauth.js

import logger from '../logger';  
import { httpPost } from '../util/http';  
import config from '../config';

let refreshPromise;

function getSFToken() {  
  const { oauth2_url, username, password, security_token, client_id, client_secret } = config.salesforce;
  const url = `${oauth2_url}?grant_type=password&username=${username}&password=${password}${security_token}&client_id=${client_id}&client_secret=${client_secret}`;
  return httpPost(url, {}, { timeout: 15000});
}

export function expireToken() {  
  refreshPromise = undefined;
}

export function getAccessToken() {  
  if (refreshPromise) {
    return refreshPromise;
  } else {
    refreshPromise = new Promise((resolve, reject) => { 
      getSFToken().then((accessToken) => {
        logger.info(`Retrieved access token. Issued at: ${accessToken.issued_at}`);
        resolve(accessToken);
      }).catch((err) => {
        logger.error(`Could not retrieve acccess token with error: ${JSON.stringify(err)}`);
        expireToken();
        reject(err);
      }); 
    }); 

    return refreshPromise;
  }
}

Ignoring the implementation details of httpPost etc let's take a look at the important bits.

We always use getAccessToken() which returns the refreshPromise. This is the most important point. This means that if the token has already expired and someone has already made a call to refresh the token, all the users just have to wait on that one promise to finish. That eliminates multiple calls to refresh the token because they're all looking at the same promise.

But what if the token expires before the session times out? By session timeout I don't mean the web session but the session for the token which is defined in the connectedApp in Salesforce. We will deal with that in the retry which I'll demonstrate in a bit.

Here's the test for the OAuth service:
salesforceOAuth.spec.js

import chai, { expect } from 'chai';  
import sinonChai from 'sinon-chai';  
import nock from 'nock';  
import config from '../config';  
import clone from 'clone';

chai.use(sinonChai);

const configStub = clone(config); 

configStub.salesforce = {  
  oauth2_url: 'http://localhost/',
  security_token: 'token',
  username: 'user',
  password: 'pass',
  client_id: 'clientid',
  client_secret: 'secret'
};

const proxyquire = require('proxyquire').noCallThru();  
const auth = proxyquire('./salesforceOAuth',  
  { '../config': configStub }
);

const { oauth2_url, security_token, username, password, client_id, client_secret } = configStub.salesforce;  
const post_params = `/?grant_type=password&username=${username}&password=${password}${security_token}&client_id=${client_id}&client_secret=${client_secret}`;

describe('salesforce OAuth service', () => {

  describe('given valid salesforce credentials', () => {

    afterEach(() => {
      auth.expireToken();
    }); 

    it('should set accessToken', () => {
      nock(oauth2_url)
        .post(post_params, {})  
        .reply(200, {
          access_token: 'token',
          issued_at: '123456789',
          instance_url: 'https://url.com'
        });

      return auth.getAccessToken().then((token) => {
        expect(token.access_token).to.equal('token');
        expect(token.issued_at).to.equal('123456789');
        expect(token.instance_url).to.equal('https://url.com');
      }); 
    }); 
  }); 

describe('given invalid salesforce credentials or invalid connected app credentials', () => {

    it('should return invalid client credentials error', () => {
      nock(oauth2_url)
        .post(post_params, {})
        .reply(400, {
          error: 'invalid_client',
          error_description: 'invalid client credentials'
        });

      const expectedError = { errors: [ { errorCode: 'generic.error' }, { errorString: 'Error: Error:400' } ] };

      return auth.getAccessToken().catch((err) => {
        expect(err).to.deep.equal(expectedError);
      });
    });

  });

  it('should return a promise when token is undefined/expired', () => {
    const expectedError = { errors:[{ errorCode: 'generic.error'}, { errorString: 'Error: Error:ECONNREFUSED' } ] };
    return auth.getAccessToken().catch(err => {
      expect(err).to.deep.equal(expectedError);
    });
  });

});

In case you aren't aware, nock simulates an http server and will only serve up the response you define. However it only serves it up once which is great because you can chain more responses to have better control of your outcome. Especially for retry methods where it might be hitting multiple endpoints. If we didn't account for a scenario on a specific path our test will fail which is what we want!
We will see this use case with nock when we get to the part that uses the retry method.

Using The Retry When Token Expires Unexpectedly

If you recall what we're going to do now is use the retryOnce(func, recoverFunc) with a service. So if the token expires it's going to try to refetch a new token which everyone will share as a Promise so we don't get multiple requests, then retry the original function.

import { httpGet } from '../util/http';  
import { getAccessToken, expireToken } from './salesforceOAuth';  
import { retryOnce } from '../util/retry';  
import logger from '../logger';

export function listTravellers(abn) {  
  return retryOnce(() => {
    return getAccessToken()
      .then(accessToken => {
        const getTravellersUrl = `${accessToken.instance_url}/services/sf_service/${abn}`;
        return httpGet(getTravellersUrl, {}); 
      })  
      .catch(err => {
        logger.error(`Failed to list travellers from Salesforce: ${JSON.stringify(err)}`);
        return Promise.reject(err);
      }); 
  }, refreshTokenOnUnauthorizedError);
}

function refreshTokenOnUnauthorizedError(err) {  
  if(JSON.stringify(err).includes('401')) { 
    expireToken();
    return getAccessToken();
  } else {
    return Promise.reject(err);
  }
}

What we did here was pass in the original function with all of its arguments by using an anonymous function. That way we don't have to pass along the arguments to the retry method. The refreshTokenOnUnAuthorizedError method is the recovery function. If it finds a 401 it'll retry.

Working with lots of promises can get tricky. Just make sure you return the original object if it's a promise and only deal with the paths you need to. Sometimes you don't even need to always cal new Promise. Just use Promise.resolve() and Promise.reject() directly. The tests are super important in exercising the code so you can have confidence that it works.

We know this will work from our tests which actually exercise the retry code but let's have some more test to make sure we get the results we're looking for:

import chai, { expect } from 'chai';  
import sinonChai from 'sinon-chai';  
import nock from 'nock';  
import travellers from '../test/travellers'; //Just an array model of travellers

chai.use(sinonChai);

const accessTokenStub = {access_token: 'token', issued_at: '1234', instance_url: 'https://pokemon.com'};  
const getAccessTokenStub = () => Promise.resolve(accessTokenStub);  
const expireTokenStub = () => Promise.resolve();  
const proxyquire = require('proxyquire').noCallThru();  
const salesforce = proxyquire('./salesforce', {  
  './salesforceOAuth': { getAccessToken: getAccessTokenStub, expireToken: expireTokenStub } 
});

describe('salesforce service', () => {

  describe('listTravellers()', () => {
    const invalidSessionError = { response: { status: [{message:'Session expired or invalid', errorCode:'INVALID_SESSION_ID'}] } };

    it('should return travellers', () => {
      nock('https://pokemon.com')
        .get('/services/sf_service/travellers/1234')
        .reply(200, travellers);

      return salesforce.listTravellers(1234).then((data) => {
        expect(data).to.deep.equal(travellers);
      }); 
    }); 

    it('should return error on invalid ABN/ABN does not exist', () => {
      const expectedError = { errors: [{ errorCode: 'generic.error'}, { errorString:'Error: Error:400' }]};

      nock('https://pokemon.com')
        .get('/services/sf_service/travellers/INVALID')
        .twice()
        .reply(400, 'Invalid ABN/ABN Does not exist');

      return salesforce.listTravellers('INVALID').catch(err => {
        expect(err).to.deep.equal(expectedError);
      }); 
    }); 

    it('should return unauthorized error on invalid token', () => {
      const expectedError = { errors: [{ errorCode: 'generic.error'}, { errorString:'Error: Error:401' }]};

      nock('https://pokemon.com')
        .get('/services/sf_service/travellers/BADTOKEN')
        .twice()
        .reply(401, invalidSessionError);

      return salesforce.listTravellers('BADTOKEN').catch(err => {
        expect(err).to.deep.equal(expectedError);
      }); 
    }); 

    it('should get travellers on second try', () => {
      nock('https://pokemon.com')
        .get('/services/sf_service/travellers/1234')
        .reply(401, invalidSessionError)
        .get('/services/sf_service/travellers/1234')
        .reply(200, travellers);

      return salesforce.listTravellers(1234).then(data => {
        expect(data).to.deep.equal(travellers);
      });
    });
  });
});

Notice we chain the http responses in nock. If we didn't, these test would fail. We want to make sure that our retry is hitting the endpoints multiple times to be inline with our expectations.

I used proxyquire to stub our the responses from getting an access token and expiring it.

I thought this was share worthy and hopefully you learned something. It was interesting to write. Thanks for reading!

comments powered by Disqus