DEV Community

Pipat Sampaokit
Pipat Sampaokit

Posted on • Updated on

Axios Interceptor Model And Pitfalls

It is common for people using Axios in their Javascript/Typescript project to use Interceptors to handle middleware stuff such as authentication headers or request/response logging.

There are plenty of examples out there that show how simple it is to use the Interceptors. You use axios.interceptors.request.use(onFulfilled, onRejected) to modify the request before it is fired, and use axios.interceptors.response.use(onFulfilled, onRejected) to handle the response before it is returned to the caller location.

But for people like me, who have a background in Java and are already familiar with the model of simple interceptors such as in Spring's RestTemplate, it is easy to misuse Axios Interceptors due to misunderstanding as we mix up the concept. Spring's RestTemplate is simple, we handle the request and response in an interceptor chain as if we call a normal method and the framework ensures that any error (Exception) in an interceptor will interrupt the chain and can be handled by the preceding interceptor with a simple try-catch.

image

Axios Interceptors, on the other hand, do not have this kind of chain interruption and error handling. What will happen if you write interceptors like this.

const myAxios = axios.create();

myAxios.interceptors.request.use(
  (config) => { console.log('interceptor2 handle config'); return config },
  (error) => { console.log('interceptor2 handle error') },
);

myAxios.interceptors.request.use((config) => {
  throw new Error('something is wrong in interceptor1');
});

myAxios
  .get('https://dev.to')
  .then(response => {
    console.log('caller handle response');
    console.log(response);
  })
  .catch(error => {
    console.log('caller handle error');
    console.log(error);
  });
Enter fullscreen mode Exit fullscreen mode

It turns out to be some mysterious error.

$ ts-node --files main.ts
interceptor2 handle error
caller handle error
TypeError: Cannot read property 'cancelToken' of undefined
Enter fullscreen mode Exit fullscreen mode

Why was the message something is wrong in interceptor1 missing? How come interceptor2 was invoked? And what was that 'cancelToken' all about? It was not clear to me at first. So I dig into it and draw this diagram to explain how it works.

image

This diagram assumes that the interceptors are registered in the order as in the following example code. You can modify this code to test and see the result yourself.


// # Use these commands to init project
// yarn init -y
// yarn add axios typescript @types/node ts-node log4js 
// npx tsc --init
// echo "console.log('hello world')" > main.ts
// npx ts-node --files main.ts

import axios from "axios";
import log4js from "log4js";

log4js.configure({
  appenders: {
    out: {
      type: "stdout",
      layout: {
        type: "pattern",
        pattern: "%d %p %f{1}(%l) %m%n",
      },
    },
  },
  categories: {
    default: { appenders: ["out"], level: "debug", enableCallStack: true },
  },
});

const logger = log4js.getLogger();

const myAxios1 = axios.create();

myAxios1.interceptors.request.use(
  config => { logger.debug('Request Interceptor3 OnFulfilled'); return config; },
  error => { logger.debug('Request Interceptor3 OnRejected: %s', error); return error; }
);

myAxios1.interceptors.request.use(
  config => { logger.debug('Request Interceptor2 OnFulfilled: %s', config); return config; },
  error => { logger.debug('Request Interceptor2 OnRejected: %s', error); return error; }
);

myAxios1.interceptors.request.use(
  config => { logger.debug('Request Interceptor1 OnFulfilled'); return config; },
  error => { logger.debug('Request Interceptor1 OnRejected: %s', error); return error; }
);

myAxios1.interceptors.response.use(
  response => { logger.debug('Response Interceptor1 OnFulfilled: %s', response.statusText); return response; },
  error => { logger.debug('Response Interceptor1 OnRejected: %s', error); return error; }
);

myAxios1.interceptors.response.use(
  response => { logger.debug('Response Interceptor2 OnFulfilled: %s', response.statusText); return response; },
  error => { logger.debug('Response Interceptor2 OnRejected: %s', error); return error; }
);

myAxios1.interceptors.response.use(
  response => { logger.debug('Response Interceptor3 OnFulfilled: %s', response.statusText); return response; },
  error => { logger.debug('Response Interceptor3 OnRejected: %s', error); return error; }
);

myAxios1
  .get("https://dev.to") // to test case api success
  // .get("https://dev.to/oh-no") // to test case api error
  .then((response) => {
    logger.debug('Caller response: %s', response.status);
  })
  .catch((err) => {
    logger.debug('Caller error: %s', err);
  });

Enter fullscreen mode Exit fullscreen mode

And here is the explanation for each label in the diagram.

  1. The first Request Interceptor will only have onFulfilled() invoked. You usually don't want to register any onRejected() for this interceptor.
  2. The second Request Interceptor can have either onFulfilled() or onRejected() invoked depending on the return value of the first interceptor. If the return value is equivalent to Promise.resolve(), then onFulfilled() will be invoked, and if it is equivalent to Promise.reject(), then onRejected() will be called.

    Please note that the following code is equivalent to Promise.resolve():

    myAxios1.interceptors.request.use(
      config => { return config; },
    );
    
    myAxios1.interceptors.request.use(
      config => { return Promise.resolve(config); },
    );
    

    And the following is equivalent to Promise.reject():

    myAxios1.interceptors.request.use(
      config => { throw 'error'; },
    );
    
    myAxios1.interceptors.request.use(
      config => { return Promise.reject('error'); },
    );
    
  3. The third interceptor doesn't care about which method is invoked previously in the second interceptor. It only cares about whether the return value is equivalent to Promise.resolve() or Promise.reject(). For example, throwing an error inside onFulfilled() of the second interceptor can invoke onRejected() on the third interceptor. Likewise, return a resolved promise in onRejected() of the second interceptor can invoke onFulfilled() on the third interceptor.

  4. If the return value of the third interceptor is equivalent to Promise.reject(), no matter it is from which method, it will invoke onRejected() on the Response Interceptor3 without sending the request to the server.

  5. If the last request interceptor's onReject() returns a resolved promise, or anything equivalent, that thing will be treated as a config object to prepare a request to send to the server. Therefore, if it is actually not a config object, a nasty exception may be thrown. This is what happened with the example in the introduction section.

  6. Some errors can be populated at Axios middleware such as the error due to request timeout or internet connection problem.

  7. If the response status code is in range 2XX, onFulfilled() on the first response interceptor will be called, and onRejected() otherwise. This logic can be customized by rewriting the function validateStatus on the config object.

  8. Like the request interceptors, which method will be called for the subsequent response interceptors is depending on the resolved/rejected promise of the previous interceptor.

  9. Finally at the caller location, the then() will be invoked if the last response interceptor yields a resolved promise, otherwise catch() will be invoked.

remark1: the onRejected() is optional, you can only provide the onFulfilled() and the default implementation will be used for onRjected() which basically is like (error) => Promise.reject(error).
remark2: When every interceptor has only the onFulfilled() defined, it is the special case in which any error that occurs inside an interceptor equivalent to a rejected promise will be chained automatically and invisibly to reach the catch() at the caller location. This effect is similar to the chain interruption but it is not the case because actually all interceptors will always be invoked behind the scene.

Example

To complete the research, the following code and diagram shows an example of a minimal interceptor chain.

image


// # Use these commands to init project
// yarn init -y
// yarn add axios typescript @types/node ts-node log4js @types/uuid
// npx tsc --init
// echo "console.log('hello world')" > main.ts
// npx ts-node --files main.ts

import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
import log4js from "log4js";
import { v4 as uuidv4 } from 'uuid';

/**
 * Configure Logger
 */

log4js.configure({
  appenders: {
    out: {
      type: "stdout",
      layout: {
        type: "pattern",
        pattern: "%d %p %f{1}(%l) %m%n",
      },
    },
  },
  categories: {
    default: { appenders: ["out"], level: "debug", enableCallStack: true },
  },
});

const logger = log4js.getLogger();

/**
 * Declare helper types and functions
 */

export interface AxiosRequestInterceptor {
    onFulfilled: (
        config: AxiosRequestConfig
    ) => AxiosRequestConfig | Promise<AxiosRequestConfig>;
    onRejected: (
        error: any
    ) => any;
}

export interface AxiosResponseInterceptor {
    onFulfilled: (
        response: AxiosResponse<any>
    ) => AxiosResponse<any> | Promise<AxiosResponse<any>>;
    onRejected: (
        error: any
    ) => any;
}

const registerRequestInterceptor = (
    instance: AxiosInstance,
    interceptor: AxiosRequestInterceptor
) => {
    instance.interceptors.request.use(
        interceptor.onFulfilled,
        interceptor.onRejected
    );
};

const registerResponseInterceptor = (
    instance: AxiosInstance,
    interceptor: AxiosResponseInterceptor
) => {
    instance.interceptors.response.use(
        interceptor.onFulfilled,
        interceptor.onRejected
    );
};

/**
 * Begin building the interceptor chain
 */

const resourceAxios = axios.create();
const authAxios = axios.create();

const state = {
    accessToken: ''
}

const RequestBasicHeadersInterceptor: AxiosRequestInterceptor = {
    onFulfilled: (config) => {
        logger.debug('RequestBasicHeadersInterceptor.onFulfilled');
        config.headers['Authorization'] = `Bearer ${state.accessToken}`;
        config.headers['X-Request-Id'] = uuidv4();
        return config;
    },
    onRejected: (error) => {
        logger.debug('RequestBasicHeadersInterceptor.onRejected');
        return Promise.reject(error);
    },
};
const RequestLoggingInterceptor: AxiosRequestInterceptor = {
    onFulfilled: (config) => {
        logger.debug('RequestLoggingInterceptor.onFulfilled');
        logger.info('%s|%s|%s|%s|%s', config.method, config.url, JSON.stringify(config.params), JSON.stringify(config.data), JSON.stringify(config.headers));
        return config;
    },
    onRejected: (error) => {
        logger.debug('RequestLoggingInterceptor.onRejected');
        return Promise.reject(error);
    },
};
const ResponseLoggingInterceptor: AxiosResponseInterceptor = {
    onFulfilled: (response) => {
        logger.debug('ResponseLoggingInterceptor.onFulfilled');
        logger.info('%s|%s|%s|%s|%s', response.config.headers['X-Request-Id'], response.status, response.statusText, JSON.stringify(response.data), JSON.stringify(response.headers));
        return response;
    },
    onRejected: (error) => {
        logger.debug('ResponseLoggingInterceptor.onRejected');
        if (error.response) {
            const response: AxiosResponse = error.response;
            logger.info('%s|%s|%s|%s|%s', response.config.headers['X-Request-Id'], response.status, response.statusText, JSON.stringify(response.data), JSON.stringify(response.headers));
        } else {
            logger.info(error);
        }
        return Promise.reject(error);
    },
};

const appConfig = {
    authEndpoint: '...',
    clientCredentialsBase64: '...'
}
const AuthRetryInterceptor: AxiosResponseInterceptor = {
    onFulfilled: (response) => {
        logger.debug('AuthRetryInterceptor.onFulfilled');
        return response;
    },
    onRejected: async (error) => {
        logger.debug('AuthRetryInterceptor.onRejected');
        if (error.response) {
            const response: AxiosResponse = error.response;
            if (response.status === 403 && !response.config.headers['X-Auth-Retry']) {
                logger.debug('AuthRetryInterceptor.onRejected: start auth retry ...');
                try {
                    const authResponse = await authAxios.post(appConfig.authEndpoint!, {}, {
                        headers: {
                            'Authorization': `Basic ${appConfig.clientCredentialsBase64}`,
                            'Content-Type': 'application/x-www-form-urlencoded',
                            'X-Request-Id': response.config.headers['X-Request-Id']
                        },
                        params: {
                            'grant_type': 'client_credentials'
                        }
                    });
                    if (authResponse.data && authResponse.data['access_token']) {
                        const accessToken = response.data['access_token'] as string;
                        state.accessToken = accessToken;
                        response.config.headers['X-Auth-Retry'] = true;
                        return resourceAxios.request(response.config);
                    } else {
                        return Promise.reject('Not found access token');
                    }
                } catch (authError) {
                    return Promise.reject(authError);
                }
            }
        }
        return Promise.reject(error);
    },
};

class ApiErrorDomain extends Error {
    constructor(msg: string) {
        super(msg);
    }
}
class ApiClientErrorDomain extends ApiErrorDomain {
    constructor(msg: string) {
        super(msg);
    }
}
class ApiServerErrorDomain extends ApiErrorDomain {
    constructor(msg: string) {
        super(msg);
    }
}
class ApiUnknownErrorDomain extends ApiErrorDomain{
    constructor(msg: string) {
        super(msg);
    }
}
const DomainMapperInterceptor: AxiosResponseInterceptor = {
    onFulfilled: (response) => {
        logger.debug('DomainMapperInterceptor.onFulfilled');
        return response;
    },
    onRejected: (error) => {
        logger.debug('DomainMapperInterceptor.onRejected');
        if (error.response) {
            const response: AxiosResponse = error.response;
            if (response.status >= 400 && response.status < 500) {
                throw new ApiClientErrorDomain(response.data);
            } else if (response.status >= 500) {
                throw new ApiServerErrorDomain(response.data);
            } else {
                throw new ApiUnknownErrorDomain(response.data);
            }
        }
        throw new ApiUnknownErrorDomain(error);
    },
};

registerRequestInterceptor(resourceAxios, RequestLoggingInterceptor);
registerRequestInterceptor(resourceAxios, RequestBasicHeadersInterceptor);

registerResponseInterceptor(resourceAxios, ResponseLoggingInterceptor);
registerResponseInterceptor(resourceAxios, AuthRetryInterceptor);
registerResponseInterceptor(resourceAxios, DomainMapperInterceptor);

registerRequestInterceptor(authAxios, RequestLoggingInterceptor);
registerResponseInterceptor(authAxios, ResponseLoggingInterceptor);

/**
 * Test and see the result
 */

resourceAxios
  .get("https://dev.to") // to test case api success
  // .get("https://dev.to/oh-no") // to test case api error
  .then((response) => {
    logger.debug('Caller response: %s', response.status);
  })
  .catch((err) => {
    logger.debug('Caller error: %s', err);
  });

Enter fullscreen mode Exit fullscreen mode

Top comments (0)