9

Is it possible to automatically throttle all requests going to a particular list of endpoints using axios? Perhaps using axios interceptor?

Currently I throttle the user action that sends the axios request, but the problem with that is that I have to write this everywhere I have a user action that results in some AJAX request. Like this

  const throttledDismissNotification = throttle(dismissNotification, 1000)

  const dismiss = (event: any) => {
    throttledDismissNotification();
  };

  render() {
    return (
      <Button onClick={dismiss}>Dismiss Notification</Button>
    )
  }

This results in a lot of clutter and I was wondering if this could be automated.

Something like:

if(request.url in listOfEndpointsToThrottle && request.params in cacheOfPreviousRequestsToThisEndpoint) {
  StopRequest();
}

Obviously this is pseudocode but you get the idea.

ManavM
  • 2,918
  • 2
  • 20
  • 33
  • 1
    First step is probably to add a flag in your Redux store, like `isFetching`, `isCreating`, `isUpdating`, etc…, and to disable the button making the call when this flag is already `true`. – GG. Apr 26 '19 at 13:19
  • @GG. I have something like this already implemented..a `loading` state that is set to true when you send a request and back to false when it returns. However, similar to the solution above, this clutters up the codebase and is tedious. – ManavM Apr 26 '19 at 13:26
  • 1
    @ManavM I have a SO discussion related to your question https://stackoverflow.com/questions/55919714/my-implementation-of-debounce-axios-request-left-the-promise-in-pending-state-fo see if that helps you. – Qiulang May 04 '19 at 09:39
  • It's quite easy to throttle an axios request call. The real headache is how to handle the promises that are returned from those nullified request, how should we define their behavior? Do they stay pending forever? Are other parts of your code expecting or ready to handle ever-pending promises? – hackape May 04 '19 at 16:16
  • @Qiulang just check your link. [Bergi's answer](https://stackoverflow.com/a/55922157/3617380) is a good one. The problem is hard to be generalized, and I don't see there's a perfect one-fit-all solution to throttle/debounce any promise-returning function. – hackape May 04 '19 at 16:32
  • @hackape ever-pending promise will not be handled by the promise chain at all but garbage collected. That is the gist. See my another discussion with Bergi https://stackoverflow.com/questions/55861970/what-will-happen-when-return-new-promiseresolve-reject-forgot-to-call – Qiulang May 05 '19 at 01:49
  • @Qiulang I'm not concerned that ever-pending promises have bad side effect, they don't. I'm concerned that they have no effect. When we `.then` on an axios request usually we assume it either suceed or fail, rarely do we consider ever-pending case. It will introduce overhead to the whole code base to take into consideration a third state to handle. – hackape May 05 '19 at 02:38
  • @hackape, right, that was why I was confused at first for my other question. So now I used Bergi suggestion const never = new Promise(resolve => {/* do nothing*/}) and add comment to what never means in my code. – Qiulang May 05 '19 at 03:19
  • Try to use React query, it provides deduping for you. – Hamid Shoja Mar 08 '22 at 20:47

4 Answers4

9

Perhaps you could try to use the Cancellation feature that axios provides.

With it, you can ensure that you don't have any two (or more, depending on your implementation) similar requests in a pending state.

Below, you will find a small simplified example of how to ensure that only the latest request is processed. You can adjust it a bit to make it function like a pool of requests

    import axios, { CancelToken } from 'axios';

    const pendingRequests = {};

    const makeCancellable = (headers, requestId) => {
      if (!requestId) {
        return headers;
      }

      if (pendingRequests[requestId]) {
        // cancel an existing request
        pendingRequests[requestId].cancel();
      }
      const source = CancelToken.source();
      const newHeaders = {
        ...headers,
        cancelToken: source.token
      };
      pendingRequests[requestId] = source;
      return newHeaders;
    };

    const request = ({
      url,
      method = 'GET',
      headers,
      id
    }) => {
      const requestConfig = {
        url,
        method,
        headers: makeCancellable(headers || {}, id)
      };

      return axios.request(requestConfig)
        .then((res) => {
          delete pendingRequests[id];
          return ({ data: res.data });
        })
        .catch((error) => {
          delete pendingRequests[id];
          if (axios.isCancel(error)) {
             console.log(`A request to url ${url} was cancelled`); // cancelled
          } else {
             return handleReject(error);
          }
        });
    };

    export default request;
dspring
  • 194
  • 1
  • 6
  • I don't think cancelling previous request is best solution. a) it throws error, that's an overhead user need to handle. b) request is still fired, just cancelled later. – hackape May 05 '19 at 03:02
8

It's quite easy to throttle an axios request itself. The real headache is how to handle the promises that are returned from nullified requests. What is considered sane behavior when dealing with promises that are returned from a nullified axios request? Should they stay pending forever?

I don't see any perfect solution to this problem. But then I come to a solution that is kind of cheating:

What if we don't throttle the axios call, instead we throttle the actual XMLHttpRequest?

This makes things way easier, because it avoids the promise problem, and it's easier to implement. The idea is to implement a cache for recent requests, and if a new request matches a recent one, you just pull the result from cache and skip the XMLHttpRequest.

Because of the way axios interceptors work, the following snippet can be used to skip a certain XHR call conditionally:

// This should be the *last* request interceptor to add
axios.interceptors.request.use(function (config) {
  /* check the cache, if hit, then intentionally throw
   * this will cause the XHR call to be skipped
   * but the error is still handled by response interceptor
   * we can then recover from error to the cached response
   **/ 
  if (requestCache.isCached(config)) {
    const skipXHRError = new Error('skip')
    skipXHRError.isSkipXHR = true
    skipXHRError.request = config
    throw skipXHRError
  } else {
    /* if not cached yet
     * check if request should be throttled
     * then open up the cache to wait for a response
     **/
    if (requestCache.shouldThrottle(config)) {
      requestCache.waitForResponse(config)
    }
    return config;
  }
});

// This should be the *first* response interceptor to add
axios.interceptors.response.use(function (response) {
  requestCache.setCachedResponse(response.config, response)
  return response;
}, function (error) {
  /* recover from error back to normality
   * but this time we use an cached response result
   **/
  if (error.isSkipXHR) {
    return requestCache.getCachedResponse(error.request)
  }
  return Promise.reject(error);
});
digout
  • 4,041
  • 1
  • 31
  • 38
hackape
  • 18,643
  • 2
  • 29
  • 57
  • Your example is helpful in showing how interceptors work (I didn't figure them out myself) Bu I will say return cached promise seem easier. – Qiulang May 05 '19 at 01:53
  • @Qiulang you're right. what im trying to do is basically caching the first promise returned after the request. Just that i do it in an axios-specific way. bergi's answer to your question show how to write a general purpose util, by still you need to decide when to or not to use this util. Mine show the basic idea of the caching strategy that fit to OP's case. – hackape May 05 '19 at 02:48
  • But honestly I didn't realize this *is* returning cached promise at first. Edited the answer to remove that misleading line. – hackape May 05 '19 at 02:52
  • I like this solution...hacking the interceptor to ensure that requests that match a condition can be ignored. Exactly what I was looking for..thank you. – ManavM May 05 '19 at 06:28
  • I do want to mention however, that there might be an easier way to stop the request than the skipXHRError hack here: https://github.com/axios/axios/issues/1497#issuecomment-404211504 – ManavM May 08 '19 at 09:40
  • @ManavM ^ that's different, this approach only cancel the XHR request, but the axios request's promise will resolve to null response. my solution will resolve to previously cached result. – hackape May 10 '19 at 04:21
  • I see...Although, stopping the XHR request is exactly what I needed. Your solution will basically act as a user-constructed local caching system and that's really cool, but not something I would want to implement since most browsers take care of caching requests for you. – ManavM May 10 '19 at 06:19
  • Clever, but any new requests starting before your 1st request completes are not throttled. – philw Sep 17 '21 at 14:25
1

I have a similar problem, thru my research it seems to lack a good solution. All I saw were some ad hoc solutions so I open an issue for axios, hoping someone can answer my question https://github.com/axios/axios/issues/2118

I also find this article Throttling Axios requests but I did not try the solution he suggested.

And I have a discussion related to this My implementation of debounce axios request left the promise in pending state forever, is there a better way?

Qiulang
  • 10,295
  • 11
  • 80
  • 129
1

I finish one, @hackape thank you for you answer, the code is as follows:

const pendings = {}
const caches = {}
const cacheUtils = {
   getUniqueUrl: function (config) {

     // you can set the rule based on your own requirement
     return config.url + '&' + config.method
   },
   isCached: function (config) {
     let uniqueUrl = this.getUniqueUrl(config)
     return caches[uniqueUrl] !== undefined
   },
   isPending: function (config) {
     let uniqueUrl = this.getUniqueUrl(config)
     if (!pendings[uniqueUrl]) {
       pendings[uniqueUrl] = [config]
       return false
     } else {
       console.log(`cache url: ${uniqueUrl}`)
       pendings[uniqueUrl].push(config)
       return true
     }
   },
   setCachedResponse: function (config, response) {
     let uniqueUrl = this.getUniqueUrl(config)
     caches[uniqueUrl] = response
     if (pendings[uniqueUrl]) {
       pendings[uniqueUrl].forEach(configItem => {
         configItem.isFinished = true
       })
     }
   },
   getError: function(config) {
     const skipXHRError = new Error('skip')
     skipXHRError.isSkipXHR = true
     skipXHRError.requestConfig = config
     return skipXHRError
   },
   getCachedResponse: function (config) {
     let uniqueUrl = this.getUniqueUrl(config)
     return caches[uniqueUrl]
   }
 }
 // This should be the *last* request interceptor to add
 axios.interceptors.request.use(function (config) {

    // to avoid careless bug, only the request that explicitly declares *canCache* parameter can use cache
   if (config.canCache) {

     if (cacheUtils.isCached(config)) {
       let error = cacheUtils.getError(config)
       throw error
     }
     if (cacheUtils.isPending(config)) {
       return new Promise((resolve, reject) => {
         let interval = setInterval(() => {
           if(config.isFinished) {
             clearInterval(interval)
             let error = cacheUtils.getError(config)
             reject(error)
           }
         }, 200)
       });
     } else {

       // the head of cacheable requests queue, get the response by http request 
       return config
     }
   } else {
     return config
   }
 });
Jamter
  • 435
  • 5
  • 13