Axios Interceptors retry original request and access original promise

82,620

Solution 1

Update Feb 13, 2019

As many people have been showing an interest in this topic, I've created the axios-auth-refresh package which should help you to achieve behaviour specified here.


The key here is to return the correct Promise object, so you can use .then() for chaining. We can use Vuex's state for that. If the refresh call happens, we can not only set the refreshing state to true, we can also set the refreshing call to the one that's pending. This way using .then() will always be bound onto the right Promise object, and be executed when the Promise is done. Doing it so will ensure you don't need an extra queue for keeping the calls which are waiting for the token's refresh.

function refreshToken(store) {
    if (store.state.auth.isRefreshing) {
        return store.state.auth.refreshingCall;
    }
    store.commit('auth/setRefreshingState', true);
    const refreshingCall = Axios.get('get token').then(({ data: { token } }) => {
        store.commit('auth/setToken', token)
        store.commit('auth/setRefreshingState', false);
        store.commit('auth/setRefreshingCall', undefined);
        return Promise.resolve(true);
    });
    store.commit('auth/setRefreshingCall', refreshingCall);
    return refreshingCall;
}

This would always return either already created request as a Promise or create the new one and save it for the other calls. Now your interceptor would look similar to the following one.

Axios.interceptors.response.use(response => response, error => {
    const status = error.response ? error.response.status : null

    if (status === 401) {

        return refreshToken(store).then(_ => {
            error.config.headers['Authorization'] = 'Bearer ' + store.state.auth.token;
            error.config.baseURL = undefined;
            return Axios.request(error.config);
        });
    }

    return Promise.reject(error);
});

This will allow you to execute all the pending requests once again. But all at once, without any querying.


If you want the pending requests to be executed in the order they were actually called, you need to pass the callback as a second parameter to the refreshToken() function, like so.

function refreshToken(store, cb) {
    if (store.state.auth.isRefreshing) {
        const chained = store.state.auth.refreshingCall.then(cb);
        store.commit('auth/setRefreshingCall', chained);
        return chained;
    }
    store.commit('auth/setRefreshingState', true);
    const refreshingCall = Axios.get('get token').then(({ data: { token } }) => {
        store.commit('auth/setToken', token)
        store.commit('auth/setRefreshingState', false);
        store.commit('auth/setRefreshingCall', undefined);
        return Promise.resolve(token);
    }).then(cb);
    store.commit('auth/setRefreshingCall', refreshingCall);
    return refreshingCall;
}

And the interceptor:

Axios.interceptors.response.use(response => response, error => {
    const status = error.response ? error.response.status : null

    if (status === 401) {

        return refreshToken(store, _ => {
            error.config.headers['Authorization'] = 'Bearer ' + store.state.auth.token;
            error.config.baseURL = undefined;
            return Axios.request(error.config);
        });
    }

    return Promise.reject(error);
});

I haven't tested the second example, but it should work or at least give you an idea.

Working demo of first example - because of the mock requests and demo version of service used for them, it will not work after some time, still, the code is there.

Source: Interceptors - how to prevent intercepted messages to resolve as an error

Solution 2

This could be done with a single interceptor:

let _refreshToken = '';
let _authorizing: Promise<void> | null = null;
const HEADER_NAME = 'Authorization';

axios.interceptors.response.use(undefined, async (error: AxiosError) => {
    if(error.response?.status !== 401) {
        return Promise.reject(error);
    }

    // create pending authorization
    _authorizing ??= (_refreshToken ? refresh : authorize)()
        .finally(() => _authorizing = null)
        .catch(error => Promise.reject(error));

    const originalRequestConfig = error.config;
    delete originalRequestConfig.headers[HEADER_NAME]; // use from defaults

    // delay original requests until authorization has been completed
    return _authorizing.then(() => axios.request(originalRequestConfig));
});

The rest is an application specific code:

  • Login to api
  • Save/load auth data to/from storage
  • Refresh token

Check out the complete example.

Solution 3

Why not try something like this ?

Here I use AXIOS interceptors in both directions. For the outgoing direction I set the Authorization header. For the incoming direction - if there is an error, I return a promise (and AXIOS will try to resolve it). The promise checks what the error was - if it was 401 and we see it for the first time (i.e. we are not inside the retry) then I try to refresh the token. Otherwise I throw the original error. In my case refreshToken() uses AWS Cognito but you can use whatever suits you most. Here I have 2 callbacks for refreshToken():

  1. when the token is successfully refreshed, I retry the AXIOS request using an updated config - including the new fresh token and setting a retry flag so that we do not enter an endless cycle if the API repeatedly responds with 401 errors. We need to pass the resolve and reject arguments to AXIOS or otherwise our fresh new promise will be never resolved/rejected.

  2. if the token could not be refreshed for any reason - we reject the promise. We can not simply throw an error because there might be try/catch block around the callback inside AWS Cognito


Vue.prototype.$axios = axios.create(
  {
    headers:
      {
        'Content-Type': 'application/json',
      },
    baseURL: process.env.API_URL
  }
);

Vue.prototype.$axios.interceptors.request.use(
  config =>
  {
    events.$emit('show_spin');
    let token = getTokenID();
    if(token && token.length) config.headers['Authorization'] = token;
    return config;
  },
  error =>
  {
    events.$emit('hide_spin');
    if (error.status === 401) VueRouter.push('/login'); // probably not needed
    else throw error;
  }
);

Vue.prototype.$axios.interceptors.response.use(
  response =>
  {
    events.$emit('hide_spin');
    return response;
  },
  error =>
  {
    events.$emit('hide_spin');
    return new Promise(function(resolve,reject)
    {
      if (error.config && error.response && error.response.status === 401 && !error.config.__isRetry)
      {
        myVue.refreshToken(function()
        {
          error.config.__isRetry = true;
          error.config.headers['Authorization'] = getTokenID();
          myVue.$axios(error.config).then(resolve,reject);
        },function(flag) // true = invalid session, false = something else
        {
          if(process.env.NODE_ENV === 'development') console.log('Could not refresh token');
          if(getUserID()) myVue.showFailed('Could not refresh the Authorization Token');
          reject(flag);
        });
      }
      else throw error;
    });
  }
); 
Share:
82,620

Related videos on Youtube

Tim Wickstrom
Author by

Tim Wickstrom

I possess a unique blend of business &amp; technical savvy, big-picture vision, and the drive to make that vision a reality

Updated on March 03, 2021

Comments

  • Tim Wickstrom
    Tim Wickstrom about 3 years

    I have an interceptor in place to catch 401 errors if the access token expires. If it expires it tries the refresh token to get a new access token. If any other calls are made during this time they are queued until the access token is validated.

    This is all working very well. However when processing the queue using Axios(originalRequest) the originally attached promises are not being called. See below for an example.

    Working interceptor code:

    Axios.interceptors.response.use(
      response => response,
      (error) => {
        const status = error.response ? error.response.status : null
        const originalRequest = error.config
    
        if (status === 401) {
          if (!store.state.auth.isRefreshing) {
            store.dispatch('auth/refresh')
          }
    
          const retryOrigReq = store.dispatch('auth/subscribe', token => {
            originalRequest.headers['Authorization'] = 'Bearer ' + token
            Axios(originalRequest)
          })
    
          return retryOrigReq
        } else {
          return Promise.reject(error)
        }
      }
    )
    

    Refresh Method (Used the refresh token to get a new access token)

    refresh ({ commit }) {
      commit(types.REFRESHING, true)
      Vue.$http.post('/login/refresh', {
        refresh_token: store.getters['auth/refreshToken']
      }).then(response => {
        if (response.status === 401) {
          store.dispatch('auth/reset')
          store.dispatch('app/error', 'You have been logged out.')
        } else {
          commit(types.AUTH, {
            access_token: response.data.access_token,
            refresh_token: response.data.refresh_token
          })
          store.dispatch('auth/refreshed', response.data.access_token)
        }
      }).catch(() => {
        store.dispatch('auth/reset')
        store.dispatch('app/error', 'You have been logged out.')
      })
    },
    

    Subscribe method in auth/actions module:

    subscribe ({ commit }, request) {
      commit(types.SUBSCRIBEREFRESH, request)
      return request
    },
    

    As well as the Mutation:

    [SUBSCRIBEREFRESH] (state, request) {
      state.refreshSubscribers.push(request)
    },
    

    Here is a sample action:

    Vue.$http.get('/users/' + rootState.auth.user.id + '/tasks').then(response => {
      if (response && response.data) {
        commit(types.NOTIFICATIONS, response.data || [])
      }
    })
    

    If this request was added to the queue I because the refresh token had to access a new token I would like to attach the original then():

      const retryOrigReq = store.dispatch('auth/subscribe', token => {
        originalRequest.headers['Authorization'] = 'Bearer ' + token
        // I would like to attache the original .then() as it contained critical functions to be called after the request was completed. Usually mutating a store etc...
        Axios(originalRequest).then(//if then present attache here)
      })
    

    Once the access token has been refreshed the queue of requests is processed:

    refreshed ({ commit }, token) {
      commit(types.REFRESHING, false)
      store.state.auth.refreshSubscribers.map(cb => cb(token))
      commit(types.CLEARSUBSCRIBERS)
    },
    
    • Bergi
      Bergi almost 6 years
      You can't get the "original .then() callbacks" and attach them to your new request. Instead, you will need to return a promise for the new result from the interceptor so that it will resolve the original promise with the new result.
    • Bergi
      Bergi almost 6 years
      I don't know axios or vue in detail, but would assume that something like const retryOrigReq = store.dispatch('auth/subscribe').then(token => { originalRequest.headers['Authorization'] = 'Bearer ' + token; return Axios(originalRequest) }); should do it
    • Tim Wickstrom
      Tim Wickstrom almost 6 years
      I updated the question to add additional context. I need to find a way to run the then statements from the original request. In the example it updates the notification store, as an example.
    • Dawid Zbiński
      Dawid Zbiński almost 6 years
      Would be nice to know what your subscribe action looks like, might help a little.
    • Bergi
      Bergi almost 6 years
      @TimWickstrom Yes, and the only way to run those then callbacks is to resolve the promise that the get(…) call returned. Afaics, the return value of the interceptor callback provides that ability.
    • Tim Wickstrom
      Tim Wickstrom almost 6 years
      @DawidZbiński great call see updated Questions for subscribe function
  • Tim Wickstrom
    Tim Wickstrom almost 6 years
    It returns the request that was sent to it.
  • Dawid Zbiński
    Dawid Zbiński almost 6 years
    I added a working example. You can take a look at it.
  • Tim Wickstrom
    Tim Wickstrom almost 6 years
    Thanks, I will plug in the updated code and give it a try.
  • Dawid Zbiński
    Dawid Zbiński almost 6 years
    Sure. Should be working, if I explained it good enough. This way you don't need to have any additional queries etc. in you state. The requests waiting will fire up after the interception is finished. That's the main difference.
  • Tim Wickstrom
    Tim Wickstrom almost 6 years
    Good, reading through the code that was my first question. There could be MANY requests attempted while the access_token is being refreshed.
  • Dawid Zbiński
    Dawid Zbiński almost 6 years
    Sure, and the answer is: it's still working properly, but without you having to create some sort of query. If there's any call on the api while the token is refreshing, it's going to return the same Promise object for each of the request. The Promise object is the object of the "access token obtaining" which, after it's done, will fire all the requests that subscribed to it while it was loading. The working example is updated... you can take a look at AppComponent tryOut method.
  • Tim Wickstrom
    Tim Wickstrom almost 6 years
    Thanks for the quick response. I will plug this into my app and report back my findings
  • Tim Wickstrom
    Tim Wickstrom almost 6 years
    Dawid, you are the man. Works like a champ with some minor updates.
  • Dawid Zbiński
    Dawid Zbiński almost 6 years
    Great to hear that. If you find some time, please, edit my answer with things you've changed, so it would help someone else who's also struggling with this. Thanks.
  • Tim Wickstrom
    Tim Wickstrom almost 6 years
    Of course! I had to wait 24 hours before I could award it.
  • Tim Wickstrom
    Tim Wickstrom almost 6 years
    One thing I had to add in my Axios interceptor was a check for 401 being returned from the oath/refresh method. If it returned a 401, it would be stuck in a loop. My temporary implementation of this was simply checking the path in an if statement: if(location.substring(location.length - 13, location.length) === 'login/refresh') { .. do something }
  • Tim Wickstrom
    Tim Wickstrom almost 6 years
    Last thought, Axios/Oauth is commonly used in Vue apps. It may be worth creating an NPM package for this that extends Axios. If you are interested i'd be more than happy to help with the project. There are a few out there but at a cursory review it does not apear they support refresh.
  • Dawid Zbiński
    Dawid Zbiński almost 6 years
    I delegated the logic of differentiating the calls to this question. Hope someone will at least throw an idea for sultion. Otherwise, if the problem is solved, I'd be more than happy to provide this interceptor as a package.
  • Dawid Zbiński
    Dawid Zbiński over 5 years
    @TimWickstrom.com if you're still interested, here is the package I created npmjs.com/package/axios-auth-refresh
  • Tim Wickstrom
    Tim Wickstrom over 5 years
    Nice work man, it looks like its getting some traction. I knew it would! I will work on incorporating it into the project soon.
  • Dawid Zbiński
    Dawid Zbiński over 5 years
    @TimWickstrom.com might still have some bugs, etc., so be careful. Also, I will surely appreciate any contributions ;)
  • Admin
    Admin over 5 years
    I was missing return new Promise( (resolve,reject) => {//refresh code} and I didn't even think to parse resolve and reject in to the .then() function. This answer could use a bit more explanation to your code though. But a solid code piece.
  • Daniel Mlodecki
    Daniel Mlodecki about 5 years
    @DawidZbiński This is really handy. Thank you for putting it together.
  • Pep
    Pep over 3 years
    What is the return Promise.reject(error); doing??
  • Dawid Zbiński
    Dawid Zbiński over 3 years
    @Pep it's returning the original error if it hasn't been intercepted, so that your original error handling can handle it.
  • Husni Jabir
    Husni Jabir over 2 years
    @DawidZbiński this is really great and you saved my day.... thanks for the sample code and I used the second approach with axios intercepter
  • Robert
    Robert almost 2 years
    Did something change in axios in regards to this? I am looking to do the same thing and the part that makes new calls with refresh token does not access original promises and original then parts. I assume that Axios.request(error.config); would always trigger original promise's then part?