Axios Interceptor Model And Pitfalls
Tags: typescript, javascript, architecture, axios
Published: 2021-04-26 22:36:49

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/97k16to1xegsv8tl9viq.png)
 
 
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.

```typescript
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);
  });
```

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
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8f5ob0ih3wfypxl4md2c.png)
 
 
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.

```typescript

// # 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);
  });

```

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():**
    ```typescript
    myAxios1.interceptors.request.use(
      config => { return config; },
    );
    
    myAxios1.interceptors.request.use(
      config => { return Promise.resolve(config); },
    );
    ```
    And the following is equivalent to **Promise.reject():**
    ```typescript
    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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q16jfeppzu7xv8xt8jh9.png)

```typescript

// # 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);
  });

```