2
\$\begingroup\$

I want some JavaScript code to take 3 things as parameters:

  • A function returning a Promise.
  • The maximum number of attempts.
  • The delay between each attempt.

What I ended up doing is using a for loop. I did not want to use a recursive function : this way, even if there are 50 attempts the call stack isn't 50 lines longer.

Here is the typescript version of the code:

/**
 * @async
 * @function tryNTimes<T> Tries to resolve a {@link Promise<T>} N times, with a delay between each attempt.
 * @param {Object} options Options for the attempts.
 * @param {() => Promise<T>} options.toTry The {@link Promise<T>} to try to resolve.
 * @param {number} [options.times=5] The maximum number of attempts (must be greater than 0).
 * @param {number} [options.interval=1] The interval of time between each attempt in seconds.
 * @returns {Promise<T>} The resolution of the {@link Promise<T>}.
 */
export async function tryNTimes<T>(
    {
        toTry,
        times = 5,
        interval = 1,
    }:
        {
            toTry: () => Promise<T>,
            times?: number,
            interval?: number,
        }
): Promise<T> {
    if (times < 1) throw new Error(`Bad argument: 'times' must be greater than 0, but ${times} was received.`);
    let attemptCount: number;
    for (attemptCount = 1; attemptCount <= times; attemptCount++) {
        let error: boolean = false;
        const result = await toTry().catch((reason) => {
            error = true;
            return reason;
        });

        if (error) {
            if (attemptCount < times) await delay(interval);
            else return Promise.reject(result);
        }
        else return result;
    }
}

The delay function used above is a promisified timeout:

/**
 * @function delay Delays the execution of an action.
 * @param {number} time The time to wait in seconds.
 * @returns {Promise<void>}
 */
export function delay(time: number): Promise<void> {
    return new Promise<void>((resolve) => setTimeout(resolve, time * 1000));
}

Is this a "good" way of doing it? And if not, how could I improve it?

\$\endgroup\$
2
\$\begingroup\$

Call stack / Job queue / Event queue

Calling an async function does not use the call stack in the way you may be used to.

Rather the async call remains on the call stack only until an await is encountered. Then the call is removed from the call stack and the await (a micro task (I think it is called)) is placed in the job queue (new to ES6). Micro tasks are very similar to the events but have higher priority (micro tasks will be executed before events)

That means that async functions can not overflow the call stack if you call it recursively as long as there is an await before the recursion.

Awaiting the recursion call will not work as the code to the right of await is executed before the current context is removed from the call stack.

The following JS example shows a recursive count down from 1billion (it will take some time but will not throw a call stack overflow)

Eg

async function recursive(c = 1e9) { // 1 billion
    if (c > 0) { 
        await Promise.resolve(); // removes call recursive from call stack
                                 // adds this micro task to the job queue
                                 // Execution is idle and thus the micro task
                                 // is removed from the queue and next line is
                                 // executed
        return recursive(c - 1);
    }
    return c;
}
recursive().then(console.log); // will in time display 0

Recursion

There is no danger in using a recursive and less complex style for your solution. The example below outlines the principle in JS (I am too lazy to type typescript)

const MAX_TRYS = 10; TRY_TIMEOUT = 500;
function toTry() {
    return new Promise((ok, fail) => { 
        setTimeout(() => Math.random() < 0.05 ? ok("OK!") : fail("Error"), TRY_TIMEOUT);
    });
}
async function tryNTimes(toTry, count = MAX_TRYS) {
    if (count > 0) {
        const result = await toTry().catch(e => e);
        if (result === "Error") { return await tryNTimes(toTry, count - 1) }             
        return result
    }
    return `Tried ${MAX_TRYS} times and failed`;
}

tryNTimes(toTry).then(console.log);

Promise resolve/reject are micro tasks

I will point out that even if you used just plain old promises rather than a async function the result is the same. A promise adds a micro task to the job queue and will not resolve or reject until the current call stack is empty.

It is therefor safe to recursively call the calling function from the resolved/rejected promise.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.