Skip to content

Instantly share code, notes, and snippets.

@drwpow
Last active August 30, 2023 14:59
Show Gist options
  • Save drwpow/866e10915e6222f9198923592e3236f5 to your computer and use it in GitHub Desktop.
Save drwpow/866e10915e6222f9198923592e3236f5 to your computer and use it in GitHub Desktop.
Fetch retry with timeout
/**
* fetch() with retries
* @param {string} url URL to fetch
* @param {object} options All options required
* @param {number} options.timeout Max timeout, in milliseconds (default: 5000)
* @param {retries} options.retries Max number of retries (default: 2)
*/
async function fetchWithRetry(url, { timeout, retries } = { timeout: 5000, retries: 2 }) {
let data;
let controller;
for (let n = 0; n <= retries; n++) {
let timer;
try {
// 1. create new AbortController
if (!controller) controller = new AbortController();
// 2. time out after 5s
timer = setTimeout(() => {
controller.abort(); // break current loop
}, timeout);
// 3. fetch
data = await fetch(url, { signal: controller.signal }).then((data) => data.json());
// 4. clear timeout if successful
clearTimeout(timer);
return data;
} catch (err) {
clearTimeout(timer); // clear timeout just for safety
// TODO: handle HTTP errors here, or in fetch().
}
}
return data;
}
@drwpow
Copy link
Author

drwpow commented Feb 17, 2021

Main idea

Function that lets you safely retry a fetch up to a max number of times, and allows you to cancel the in-flight request and start another one if the data isn’t received within a certain number of milliseconds.

Explanation

Though throwing an error is one weird quirk of .abort(), in many ways it can be useful! Here, you can see that inside the for … loop, if we hit our timeout, controller.abort() gets called (#L21). This then throws an error, which is caught by the try/catch. We then continue on to the next cycle of the loop.

The reason we have a try/catch around the entire logic instead of using fetch(…).catch() is because as soon as we call .abort(), we immediately want to stop execution and continue to the next cycle of the loop. If we handled it in .catch() instead, we’d have to continue processing the rest of the code which may not be desirable (like for example, if we set some “success” status).

Also, a for … loop is preferable here because it means we only send one fetch request at a time.

Alternatively, if .abort() didn’t throw an error, you could still find a way to recreate this behavior. But I’d be willing to bet you couldn’t do so with as little code and with as little complexity as this.

Lastly, this is just an example. You’ll notice HTTP errors aren’t handled with this. This is only illustrative; obviously you’d need to add a few more features here to make it handle all your needs.

@thomaskonrad
Copy link

thomaskonrad commented Jul 1, 2021

Thanks for that Gist, it really helped me!

I have a few suggestions for improvement.

  1. I pass a RequestInit object to the function and pass it on to fetch with the spread operator, along with the AbortController signal. This makes the function behave more like the original fetch, so it can basically be used as a drop-in replacement.
  2. From what I see, we need to create a new AbortController instance for each request, because otherwise, every subsequent retry will be immediately aborted. This is at least the behavior that I see in the latest version of Firefox (I haven't tested any other browsers yet).
  3. Also, I throw the error if it's not an instance of DOMException (which happens when aborting), so the error handling that happens outside the function behaves more or less like a normal fetch call.
  4. If I get to the end of the function, that means that none of the tries was successful. I also throw an error in that case.
export default async function fetchWithRetry(
    url: string,
    init?: RequestInit,
    { timeoutInSeconds, tries } = { timeoutInSeconds: 10, tries: 3 },
): Promise<Response> {
    let response: Response;
    let controller: AbortController;

    for (let n = 0; n < tries; n++) {
        let timeoutID;

        try {
            controller = new AbortController();

            timeoutID = setTimeout(() => {
                controller.abort(); // break current loop
            }, timeoutInSeconds * 1000);

            response = await fetch(
                url,
                { ...init, signal: controller.signal },
            );

            clearTimeout(timeoutID);
            return response;
        } catch (error) {
            if (timeoutID) {
                clearTimeout(timeoutID);
            }

            if (!(error instanceof DOMException)) {
                // Only catch abort exceptions here. All the others must be handled outside this function.
                throw error;
            }
        }
    }

    // None of the tries finished in time.
    throw new Error(
        `Request timed out (tried it ${tries} times, but none finished within ${timeoutInSeconds} second(s)).`
    );
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment