Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
April 29, 2023 10:56 am GMT

Adding timeout and multiple abort signals to fetch() (TypeScript/React)

Table of contents

  • Timeout
    • 1. Using setTimeout()
    • 2. Using AbortController.timeout()
  • The Problem with only one abort signal
    • Using fetch() in React
  • Combining two abort signals
    • 1. Using setTimeout()
    • 2. Using AbortController.timeout()
  • Adding more signals
    • 1. Using setTimeout()
    • 2. Using AbortController.timeout()
  • Adding multiple signals to Axios
  • NPM Packages
  • Resources
  • Credits
  • P.S.

Timeout

In my latest project, I made a Node/Express API backend which acted as a proxy between multiple public APIs and my frontend. after deploying it to Vercel, I encountered this error: The Serverless Function has timed out.
This happens when one of the upstream APIs takes too long to respond but could also happen with slow database connections.
Vercel has a guide on this error that explains why it happens and that your backend should return a response to the client if the upstream service takes too long and not wait forever for the response.

I used the Fetch API to pull data from upstream APIs in my project so I decided to remedy the problem by adding timeout to my fetch requests.

Note: the default timeout for a fetch() request is determined by the browser/environment and can't be changed.

There are two main ways to do this:

1. Using setTimeout()

We can abort a fetch() request by providing it with an AbortController signal:

const myFunction = async () => {  const controller = new AbortController();  const res = await fetch('url', { signal: controller.signal });};

Then we can call the controller.abort() method to abort the request.

const reason = new DOMException('message', 'name');controller.abort(reason);

So the only thing left to do is to use setTimeout() to call controller.abort() after a set period of time.

const timeoutId = setTimeout(() => controller.abort(), timeout);...clearTimeout(timeoutId);

Note: You should always use clearTimeout() to cancel your setTimeout(), otherwise it'll continue running in the background!

After putting it together the function looks like this:

const fetchTimeout = async (input, init = {}) => {  const timeout = 5000; // 5 seconds  const controller = new AbortController();  const reason = new DOMException('signal timed out', 'TimeoutError');  const timeoutId = setTimeout(() => controller.abort(reason), timeout);  const res = await fetch(input, {    ...init,    signal: controller.signal,  });  clearTimeout(timeoutId);  return res;};

There's a bug! if fetch() throws an error, the next line (clearTimeout()) won't run and setTimeout() will continue running in the background.

To fix it we can use try...catch to clear the timeout when an error happens.

let res;try {  res = await fetch(input, {    ...init,    signal: controller.signal,  });} catch (error) {  clearTimeout(timeoutId);  throw error;}

We can further improve it by extending RequestInit and adding timeout, so we can call it using: fetchTimeout('...', { timeout: 5000 });. And because we have to clearTimeout() whether there's an error or not, we can put it in finally{} instead.
The final function (with types added):

interface RequestInitTimeout extends RequestInit {  timeout?: number;}const fetchTimeout = async (  input: RequestInfo | URL,  initWithTimeout?: RequestInitTimeout) => {  // if no options are provided, do regular fetch  if (!initWithTimeout) return await fetch(input);  const { timeout, ...init } = initWithTimeout;  // if no timeout is provided, do regular fetch with options  if (!timeout) return await fetch(input, init);  // else  const controller = new AbortController();  const reason = new DOMException(    `signal timed out (${timeout}ms)`,    'TimeoutError'  );  const timeoutId = setTimeout(() => controller.abort(reason), timeout);  let res;  try {    res = await fetch(input, {      ...init,      signal: controller.signal,    });  } finally {    clearTimeout(timeoutId);  }  return res;};

Note: In an async function, returning a value acts as resolve and throwing an error acts as reject which means we can use .then(), .catch(), and .finally() with this function as well.

2. Using AbortController.timeout()

There's a newer and easier way of achieving this using AbortController.timeout() which will give you a signal that will automatically abort() after the set timeout that you can pass to fetch():

const myFunction = async () => {  const signal = AbortSignal.timeout(5000); // 5 seconds  const res = await fetch('url', { signal });}

Note: { signal } is the shorthand for { signal: signal }

We can modify the fetchTimeout() function to use AbortController.timeout() instead:

interface RequestInitTimeout extends RequestInit {  timeout?: number;}const fetchTimeout = async (  input: RequestInfo | URL,  initWithTimeout?: RequestInitTimeout) => {  // if no options are provided, do regular fetch  if (!initWithTimeout) return await fetch(input);  const { timeout, ...init } = initWithTimeout;  // if no timeout is provided, do regular fetch with options  if (!timeout) return await fetch(input, init);  // else  const signal = AbortSignal.timeout(timeout);  const res = await fetch(input, {    ...init,    signal,  });  return res;};

AbortSignal.timeout() gives us a few advantages:

  • We don't have to specify an error message as an appropriate TimeoutError is thrown by default
  • We don't have to use setTimeout() and clearTimeout()
  • We don't need to use try...catch

The Problem with only one abort signal

After writing the above functions and using them in my Node/Express API, in case of a fetch timeout, I could return an error like: selected API took too long to respond. and I didn't get the The Serverless Function has timed out. error anymore.
Now I wanted to use the same function in my frontend React app as well and show an error in case of timeout (and refetch after a while), instead of waiting for the fetch() forever. but I encountered a problem.

Using fetch() in React

To run a fetch() request in React we use the useEffect() hook so we can run it only once when the component mounts or when a dependency changes instead of re-fetching on every component render:

Note: The wrong way

useEffect(() => {  const getData = async () => {    const res = await fetch('url');    ...  };  getData();}, []);

But there's a big problem with this code. when our component unmounts, the async function / fetch request continues to run. it may not be obvious in small applications but it causes many problems:

  • If the component mounts/unmounts 100 times, we'll have 100 concurrent fetch requests running!
  • If the component unmounts and a new component is showing, the logic in the old component's async function will still run and update the state/data!
  • An older fetch request may take longer to complete than the newer one due to network conditions and will update our data/state to the old values!

To fix the problem we have to run a clean-up function and abort the fetch request on component unmount. we can do it by returning a function in the useEffect() hook:

Note: The correct way

useEffect(() => {  const getData = async (signal: AbortSignal) => {    const res = await fetch('url', { signal });    ...  };  const controller = new AbortController();  const signal = controller.signal;  const reason = new DOMException('cleaning up', 'AbortError');  getData(signal);  return () => controller.abort(reason);}, []);

Note: You can use controller.abort() without providing reason but it can be helpful for debugging.

And here's where we encounter the problem. in the fetchTimeout() function, we use either a signal we created ourselves or the AbortSignal.timeout() signal to abort the fetch request on a timeout. but to abort the request on component unmount as well, we need a second signal and according to MDN:

"At time of writing there is no way to combine multiple signals. This means that you can't directly abort a download using either a timeout signal or by calling AbortController.abort(). "

So..., lets do exactly that!

Combining two abort signals

To add another signal to our function, we need to make it so the
abort() method of the second signal triggers the abort() method of our timeout signal. we can achieve this by adding an abort event listener to the second signal:

const controller = new AbortController();secondSignal.addEventListener(  'abort',  () => {    controller.abort(secondSignal.reason);  },  { signal: controller.signal });

Note: When we pass a signal in the options (3rd parameter) of the addEventListener() function, the event is removed when the signal is aborted.

The modified functions would look like this:

1. Using setTimeout()

interface RequestInitTimeout extends RequestInit {  timeout?: number;}const fetchTimeout = async (  input: RequestInfo | URL,  initWithTimeout?: RequestInitTimeout) => {  // if no options are provided, do regular fetch  if (!initWithTimeout) return await fetch(input);  const { timeout, ...init } = initWithTimeout;  // if no timeout is provided, do regular fetch with options  if (!timeout) return await fetch(input, init);  // else  const controller = new AbortController();  const reason = new DOMException('signal timed out', 'TimeoutError');  const timeoutId = setTimeout(() => controller.abort(reason), timeout);  const signal = init.signal;  // if fetch has a signal  if (signal) {    // if signal is already aborted, abort timeout signal    if (signal.aborted) controller.abort(signal.reason);    // else add on signal abort: abort timeout signal    else      signal.addEventListener(        'abort',        () => {          controller.abort(signal.reason);          clearTimeout(timeoutId);        },        { signal: controller.signal }      );  }  let res;  try {    res = await fetch(input, {      ...init,      signal: controller.signal,    });  } finally {    clearTimeout(timeoutId);  }  return res;};

2. Using AbortController.timeout()

Because we can't manually abort() the AbortController.timeout() signal, we will need a third signal. then we add event listeners to both the input signal and the timeout signal to abort() the third signal:

interface RequestInitTimeout extends RequestInit {  timeout?: number;}const fetchTimeout = async (  input: RequestInfo | URL,  initWithTimeout?: RequestInitTimeout) => {  // if no options are provided, do regular fetch  if (!initWithTimeout) return await fetch(input);  const { timeout, ...init } = initWithTimeout;  // if no timeout is provided, do regular fetch with options  if (!timeout) return await fetch(input, init);  // else  const timeoutSignal = AbortSignal.timeout(timeout);  let controller: AbortController;  let thirdSignal: AbortSignal;  // input signal  const inputSignal = init.signal;  // if fetch has a signal  if (inputSignal) {    // third signal setup    controller = new AbortController();    thirdSignal = controller.signal;    timeoutSignal.addEventListener(      'abort',      () => {        controller.abort(timeoutSignal.reason);      },      { signal: thirdSignal }    );    // if input signal is already aborted, abort third signal    if (inputSignal.aborted) controller.abort(inputSignal.reason);    // else add on signal abort: abort third signal    else      inputSignal.addEventListener(        'abort',        () => {          controller.abort(inputSignal.reason);        },        { signal: thirdSignal }      );  }  return await fetch(input, {    ...init,    signal: inputSignal ? thirdSignal! : timeoutSignal,  });};

Note: The reason I've not used signal.onabort instead of signal.addEventListener() is that then we would need to iterate through all of the signals and remove it once the new signal is aborted. but providing the new signal to addEventListener() saves us from doing that.

Adding more signals

We can do what we did for merging two signals for any number of signals. the modified functions and interface that accept a signals array and abort when any one of the signals is aborted:

1. Using setTimeout()

interface RequestInitMS extends RequestInit {  timeout?: number;  signals?: Array<AbortSignal>;}const fetchMS = async (  input: RequestInfo | URL,  initMS?: RequestInitMS) => {  // if no options are provided, do regular fetch  if (!initMS) return await fetch(input);  let { timeout, signals, ...init } = initMS;  // if no timeout or signals is provided, do regular fetch with options  if (!timeout && !signals) return await fetch(input, init);  signals ||= [];  // if signal is empty and signals only has one item,  // set signal to it and do regular fetch  if (signals.length === 1 && !init.signal)    return await fetch(input, { ...init, signal: signals[0] });  // if signal is set, push to signals array  init.signal && signals.push(init.signal);  const controller = new AbortController();  // timeout setup  let timeoutId: ReturnType<typeof setTimeout>;  if (timeout) {    const reason = new DOMException(      `signal timed out (${timeout}ms)`,      'TimeoutError'    );    timeoutId = setTimeout(() => controller.abort(reason), timeout);  }  // add event listener  for (let i = 0, len = signals.length; i < len; i++) {    // if signal is already aborted, abort timeout signal    if (signals[i].aborted) {      controller.abort(signals[i].reason);      break;    }    // else add on signal abort: abort timeout signal    signals[i].addEventListener(      'abort',      () => {        controller.abort(signals![i].reason);        timeout && clearTimeout(timeoutId);      },      { signal: controller.signal }    );  }  let res;  try {    res = await fetch(input, {      ...init,      signal: controller.signal,    });  } finally {    timeout && clearTimeout(timeoutId!);  }  return res;};

Note: In browser, setTimeout() returns number but in Node.js, it returns NodeJS.Timeout. setting the type of timeoutId to ReturnType<typeof setTimeout>, sets it's type to whatever the return type of setTimeout() is at that moment and allows the code to run in both environments.

2. Using AbortController.timeout()

interface RequestInitMS extends RequestInit {  timeout?: number;  signals?: Array<AbortSignal>;}const fetchMS = async (  input: RequestInfo | URL,  initMS?: RequestInitMS) => {  // if no options are provided, do regular fetch  if (!initMS) return await fetch(input);  let { timeout, signals, ...init } = initMS;  // if no timeout or signals is provided, do regular fetch with options  if (!timeout && !signals) return await fetch(input, init);  signals ||= [];  // if signal is empty and signals only has one item,  // set signal to it and do regular fetch  if (signals.length === 1 && !init.signal)    return await fetch(input, { ...init, signal: signals[0] });  // if signal is set, push to signals array  init.signal && signals.push(init.signal);  const controller = new AbortController();  // timeout setup  if (timeout) {    const timeoutSignal = AbortSignal.timeout(timeout);    signals.push(timeoutSignal);  }  // add event listener  for (let i = 0, len = signals.length; i < len; i++) {    // if signal is already aborted, abort timeout signal    if (signals[i].aborted) {      controller.abort(signals[i].reason);      break;    }    // else add on signal abort: abort timeout signal    signals[i].addEventListener(      'abort',      () => {        controller.abort(signals![i].reason);      },      { signal: controller.signal }    );  }  return await fetch(input, {    ...init,    signal: controller.signal,  });};

5. Adding multiple signals to Axios

Axios already has a timeout built in and can be aborted using an AbortSignal as well. so in a situation like the mentioned useEffect(), it should work without any modifications:

useEffect(() => {  const getData = async (signal: AbortSignal) => {    const res = await axios.get('url', { timeout: 5000, signal });    ...  };  const controller = new AbortController();  const signal = controller.signal;  const reason = new DOMException('cleaning up', 'AbortError');  getData(signal);  return () => controller.abort(reason);}, []);

But if for any reason you need more signals, I've made a utility function that takes multiple signals as input and returns a signal that will abort when any of the input signals are aborted:

const multiSignal = (...inputSignals: AbortSignal[] | [AbortSignal[]]) => {  const signals = Array.isArray(inputSignals[0])    ? inputSignals[0]    : (inputSignals as AbortSignal[]);  // if only one signal is provided, return it  const len = signals.length;  if (len === 1) return signals[0];  // new signal setup  const controller = new AbortController();  const signal = controller.signal;  // add event listener  for (let i = 0; i < len; i++) {    // if signal is already aborted, abort new signal    if (signals[i].aborted) {      controller.abort(signals[i].reason);      break;    }    // else add on signal abort: abort new signal    signals[i].addEventListener(      'abort',      () => {        controller.abort(signals[i].reason);      },      { signal }    );  }  return signal;};

Note: (...inputSignals) allows us to access all of the function's arguments with the inputSignals variable, which in this case is either a tuple of an AbortSignal array or an AbortSignal array itself. this allows us to call the function with multiple signal arguments: multiSignal(s1, s2, s3) as well an array of signals: multiSignal([s1,s2,s3]).

You can add multiple signals to Axios using it:

const controller1 = new AbortController();const controller2 = new AbortController();const res = await axios.get('url', {  signal: multiSignal(controller1.signal, controller2.signal),  timeout: 5000,});

Also, If you don't need the timeout option in the fetchTimeout() function we made above, you can use AbortSignal.timeout() along with multiSignal() to achieve the same result with fetch():

const controller = new AbortController();const signal = controller.signal;const timeoutSignal = AbortSignal.timeout(5000);const res = await fetch('url', {  signal: multiSignal(signal, timeoutSignal),});

Note: multiSignal() can be used in any function that accepts an AbortSignal().

fetchMS() function can be simplified using multiSignal():

import multiSignal from 'multi-signal';interface RequestInitMS extends RequestInit {  timeout?: number;  signals?: Array<AbortSignal>;}const fetchMS = async (input: RequestInfo | URL, initMS?: RequestInitMS) => {  // if no options are provided, do regular fetch  if (!initMS) return await fetch(input);  let { timeout, signals, ...init } = initMS;  // if no timeout or signals is provided, do regular fetch with options  if (!timeout && !signals) return await fetch(input, init);  signals ||= [];  // if signal is empty and signals only has one item,  // set signal to it and do regular fetch  if (signals.length === 1 && !init.signal)    return await fetch(input, { ...init, signal: signals[0] });  // if signal is set, push to signals array  init.signal && signals.push(init.signal);  // timeout setup  if (timeout) {    const timeoutSignal = AbortSignal.timeout(timeout);    signals.push(timeoutSignal);  }  return await fetch(input, {    ...init,    signal: multiSignal(signals),  });};

7. NPM Packages

Finally, I've published the fetchMS() and multiSignal() as packages on NPM so you can install and use them easily. check the package readme for more information:

fetchMS() : fetch-multi-signal
multiSignal(): multi-signal

Resources

  • Proposal: fetch with multiple AbortSignals - I got the idea of merging multiple signals from here.
  • any-signal - After writing this post, I noticed that there's already a package that does what multiSignal() does. I suggest using that package in production instead, as I'm just doing this for learning purposes and my implementation could be buggy/incomplete.

Credits

Cover photo by Israel Palacio on Unsplash

P.S.

ChatGPT


Original Link: https://dev.to/rashidshamloo/adding-timeout-and-multiple-abort-signals-to-fetch-typescriptreact-33bb

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To