Skip to content

Instantly share code, notes, and snippets.

@drwpow
Last active September 28, 2024 13:28
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;
}
@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