axios-cache-adapter icon indicating copy to clipboard operation
axios-cache-adapter copied to clipboard

Does it works when duplicate requests run in same time ?

Open cnhuye opened this issue 3 years ago • 7 comments

In my situation,

const http = axios.create({
...
});
http.get('/users');
http.get('/users');
http.get('/users');
http.get('/users');

4 requests run in same time, none of them use cache, I have 4 xhr requests.

Does axios-cache-adapter support caching request before the first request finished, so I can have only 1 real request ?

cnhuye avatar Feb 11 '21 19:02 cnhuye

Hello @cnhuye

I don't think that is possible, well at least not cached automatically by the adapter. We need at least one response else we can't know what to cache.

It would be possible to push data to the cache store manually.

ghost avatar Feb 17 '21 08:02 ghost

One solution would be to put a promise queue in front of the cache-adapter and debounce duplicate requests from there.

jmcpheeATwestjetCom avatar Feb 19 '21 22:02 jmcpheeATwestjetCom

I've got a solution for this if you're interested. I wrote a custom debounce that works like this:

import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { MD5 } from 'object-hash';

const hash = MD5;

export type CustomGet = {
  <T = any, R = AxiosResponse<T>>(
    url: string,
    config?: CustomHttpRequestConfig,
  ): Promise<R>;
};

type CustomHttpRequestConfig = Partial<AxiosRequestConfig> & {
  debounce?: boolean;
  ttl?: number;
};

type Modify<T, R> = Omit<T, keyof R> & R;
export type CustomHttpInstance = Modify<
  AxiosInstance,
  {
    get: CustomGet;
  }
>;

export const debounce = (axios: AxiosInstance): CustomHttpInstance => {
  const _cache: { [key: string]: Promise<any> } = {};

  const cache = (
    axiosMethod: CustomGet,
    url: string,
    config: CustomHttpRequestConfig = {
      debounce: true,
      ttl: 2000,
    },
  ) => {
    const cachekey: string = hash(url);

    if (config.debounce === false) {
      return axiosMethod(url, config);
    }

    if (!_cache[cachekey]) {
      _cache[cachekey] = axiosMethod(url, config);
    }

    setTimeout(() => {
      delete _cache[cachekey];
    }, config.ttl);

    return _cache[cachekey];
  };

  const get: CustomGet = (url, config) => cache(axios.get, url, config);

  return {
    ...axios,
    get,
  } as CustomHttpInstance;
};

This will expose a client that can take extra debounce and ttl options in the GET request method. For our needs, we specifically only wanted the functionality for GET requests, but you can see how easy it would be to extend this for other HTTP methods here.

citypaul avatar Feb 26 '21 11:02 citypaul

@citypaul Hi,

One gotcha I found doing the debouncing was that returning just the cached value might cause issues if you are using any axios transforms or interceptors.

The data gets cached prior to being processed by the transforms (and/or interceptors, I forget exactly). It looks like your method above is just returning the cached data.

The implementation we are using waits for the axios response and then calls the axios.get with the next request in the Queue. Assuming the first request is cached that will bypass the network request while still getting the response processed by the interceptors/transforms.

We also implemented a dedicated queue cache in case the native request did not have a cache configured for it.

jmcpheeATwestjetCom avatar Feb 26 '21 18:02 jmcpheeATwestjetCom

A simple workaround (or even something can be eventually added) is to have an "inflight request" map in front of the cache to store the unresolved promises. So when the promise resolves, All the thens get the data, and it's safe to consider the request is done, so it can be deleted from the map.

const inflightMap = new Map();
const http = axios.create({
...
});

// ...

if(!inflightMap.has('/users') {
  const promise = http.get('/users');
  inflightMap.set('/users', promise);
} else {
  await inflightMap.get('/users');
  await inflightMap.get('/users');
  await inflightMap.get('/users');
  await inflightMap.get('/users');
  inflightMap.delete('./users')  
}



wctiger avatar Apr 17 '21 03:04 wctiger

This is what I did and works!

Ignore this, go below to the correction

import axios from 'axios'
import md5 from 'md5'
import { setupCache, serializeQuery } from 'axios-cache-adapter'

const { adapter: axiosCacheAdapter, cache } = setupCache({
  debug: process.env.NODE_ENV !== 'production',
  exclude: {
    query: false
  },
  maxAge: 15 * 60 * 1_000
})

// ------------------------------------------------------- Magic starts here -------------------------------------------------------
const runningRequests = {}

const noDuplicateRequestsAdapter = async request => {
  const requestUrl = `${request.baseURL ? request.baseURL : ''}${request.url}`
  let requestKey = requestUrl + serializeQuery(request)

  if (request.data) requestKey = requestKey + md5(request.data)

  // If there is an exactly equal running request
  // wait for running request finishes an returns that response
  if (runningRequests[requestKey]) return await runningRequests[requestKey]

  // Otherwise
  // Process the request and add it to running requests
  runningRequests[requestKey] = axiosCacheAdapter(request)

  const response = await runningRequests[requestKey]

  // Delete finished request
  delete runningRequests[requestKey]

  return response
}

const axiosInstance = axios.create({
  // Add the adapter and see how magic happens
  adapter: noDuplicateRequestsAdapter,
  baseURL: process.env.VUE_APP_API
})

axiosInstance.cache = cache

// ------------------------------------------------------- Magic ends here -------------------------------------------------------

This is an improvement and correction for my previous answer at 2021-07-14, because I didn't consider an error response and I never delete that errored request from runningRequests

Check the correction:

import axios from 'axios'
import md5 from 'md5'
import { setupCache, serializeQuery } from 'axios-cache-adapter'

const { adapter: axiosCacheAdapter, cache } = setupCache({
  debug: process.env.NODE_ENV !== 'production',
  exclude: {
    query: false
  },
  maxAge: 15 * 60 * 1_000
})

// ------------------------------------------------------- Magic starts here -------------------------------------------------------
const runningRequests = {}

const noDuplicateRequestsAdapter = request => {
  const requestUrl = `${request.baseURL ? request.baseURL : ''}${request.url}`
  let requestKey = requestUrl + serializeQuery(request)

  if (request.data) requestKey = requestKey + md5(request.data)

  // Add the request to runningRequests
  if (!runningRequests[requestKey]) runningRequests[requestKey] = axiosCacheAdapter(request)

  // Return the response promise
  return runningRequests[requestKey].finally(() => {
    // Finally, delete the request from the runningRequests whether there's error or not
    delete runningRequests[requestKey]
  })
}

const axiosInstance = axios.create({
  // Add the adapter and see how magic happens
  adapter: noDuplicateRequestsAdapter,
  baseURL: process.env.VUE_APP_API
})

axiosInstance.cache = cache

// ------------------------------------------------------- Magic ends here -------------------------------------------------------

KristianAlonso avatar Jul 15 '21 00:07 KristianAlonso

I was having the same problem. I tried to fix it (and some other things), but I ended up modifying so many things in this package that it felt like it would be more worthwhile to create another one. So i created axios-cache-interceptor to solve this and other problems I had.

We are already using it in production and it has enough unit tests, so far so good. If you want to take a look: https://github.com/ArthurFiorette/axios-cache-interceptor

I'm sorry for posting another project here, but maybe it will help more people

arthurfiorette avatar Sep 23 '21 10:09 arthurfiorette