Search code examples
javascriptasync-awaitpromisethrottling

How to throttle a promise that recursively retries X number of times


I am trying to write a promise that will be consumed by a react component. I am using lodash for throttling. The idea is the component using this function should transparently invoke this function as many time as it wants without causing too many invocations.

let throttledApi = throttle(() => {
    // tracking number of attempts
  let attempts = 0;
  const retryableFn = () => {
    // do I need a return before api here?
    api()
      .then((response) => {
        return response;
      })
      .catch((error) => {
        attempts++;
        if (attempts === 3) throw error; // throw to the component using this functino
                // same question as above for below line
        else retryableFn();
      });
  };
}, 60 * 1000); // 1 minute


// I am using it like this
throttledFn = throttledApi();
// How do I properly use the throttled instance
// How do I try/catch properly.
// Is this thenable?
throttledFn();


Solution

  • There are a couple of issues, but even when those are fixed, you actually still need a promise from the throttled function, which lodash does not provide.

    The issues:

    • retryableFn is never called. The function you have passed to throttle only defines that function, but then does nothing with it.

    • throttledApi is the throttled function, so doing throttledFn = throttledApi() is actually executing that throttled function and assigning its return value (which is undefined) to throttledFn. You should just work with throttledApi and forget about throttledFn

    • The function that lodash's _.throttle creates for you doesn't return a value, so it will not be a promise (thenable) as you understandably prefer.

    Throttling for promises

    The function returned by the lodash throttle function does not return a value, yet in your scenario it would be good to still get a promise from it. If the request comes too fast after another one, then this promise should take some time (including the delay) to resolve.

    You can write your own throttleAsync function. Here is one way it could be done (I've used 4 seconds as cooldown):

    // Generic function
    const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
    
    // Generic function
    const retryAsync = (asyncFunc, maxNumAttempts, timeout) => 
        asyncFunc().catch(async (error) => {
            if (--maxNumAttempts <= 0) throw error;
            await delay(timeout);
            return retryAsync(asyncFunc, maxNumAttempts, timeout);
        });
    
    // Generic function
    const throttleAsync = (asyncFunc, coolDown) => {
        let coolDownPromise = Promise.resolve();
        let responsePromise = null;
        return async function(...args) {
            console.log("Verify cooldown");
            await coolDownPromise;
            if (responsePromise) return responsePromise;
            console.log("Cooldown is over...");
            coolDownPromise = delay(coolDown);
            responsePromise = asyncFunc.call(this, ...args);
            responsePromise.finally(() => responsePromise = null).catch(() => 0);
            return responsePromise;
        };
    }
    
    // Specific function (mock)
    const api = async function () {
        console.log("API called");
        await delay(100);
        if (Math.random() < .5) {
            console.log("API bumped into an error.");
            throw "API bumped into an error.";
        }
        console.log("Response received from API!");
        return { data: new Date().toLocaleTimeString() };
    }
    
    // throttle the specific function to have 4 seconds cooldown
    const throttledRequest = throttleAsync(() => retryAsync(api, 3, 200), 4_000);
    
    async function refresh() {
        try {
            const response = await throttledRequest();
            output.textContent = "The response is: " + JSON.stringify(response);
        } catch (error) {
            output.textContent = "Giving up. The error is: " + JSON.stringify(error.message ?? error);
        }
    }
    
    // I/O handling
    const [button, output] = document.querySelectorAll("button, div");
    button.addEventListener("click", refresh);
    <script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
    <button>Make Request</button>
    <div></div>