This package has been extracted from the original fbjs library, and it exposes single fetchWithRetries
function. This function is only a wrapper for any other well known fetch API. However, this fetch also solves these common problems:
- automatic request timeouts
- automatic request retries
- manual request cancellations
This makes the fetch function more suitable for real-life production usage because it doesn't hang or fail easily. In other words you are not going to have many open connections just because the API is slow (this could kill your server completely) and your fetch won't give up if the API didn't respond for the first time (just some glitch and one retry would fix it).
Installation
yarn add @adeira/fetch
Usage
This fetch is basically drop-in replacement for any other fetch you use:
import fetch from '@adeira/fetch';
(async () => {
const response = await fetch('https://api.skypicker.com/locations?term=Barcelona');
console.log(await response.json());
})();
There are however some interesting features on top of this API. You can for example change the internal timings:
import fetchWithRetries from '@adeira/fetch';
(async () => {
try {
const response = await fetchWithRetries(
'https://cs-api.skypicker.com/public/numbers?country_code=er404',
{
fetchTimeout: 15000,
retryDelays: [1000, 3000],
},
);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error.response);
console.error(error.response.status);
console.error(error.response.statusText);
const response = await error.response.json();
console.error(response);
}
})();
It does two things:
- it will timeout the connection after 15 seconds, and
- it will retry after 1 second and then after 3 seconds
Retries are performed in these situations:
- original fetch request failed
- fetch returned HTTP status <200 or >=300 (but some not transitive HTTP status codes like 401 or 403 are ignored)
- when the timeout (
fetchTimeout
) occurs
This package uses fetch ponyfill internally (cross-fetch
) so it supports server JS as well as browsers and React Native environment. It will always try to use global fetch
if available before using this ponyfill.
Error handling
You have to catch errors while fetching the response. This fetch throws these exceptions:
- standard fetch exception (
Error
) when request failed for some reason TimeoutError
when fetch fails because of defined timeoutResponseError
when final response returns HTTP status <200 or >=300
Example:
import fetchWithRetries, { TimeoutError, ResponseError } from '@adeira/fetch';
(async () => {
try {
const response = await fetchWithRetries('//localhost');
const data = await response.json();
console.log(data);
} catch (error) {
if (error instanceof TimeoutError) {
console.error('request timeouted');
} else if (error instanceof ResponseError) {
console.error('unsuccessful response', error.response);
} else {
console.error('unknown error');
}
}
})();
Request cancellation
You can easily cancel any request via AbortController
(https://developer.mozilla.org/en-US/docs/Web/API/AbortController) like so:
const controller = new AbortController();
const signal = controller.signal;
const response = await fetchWithRetries('//localhost', {
signal,
});
controller.abort();
Frequently Asked Questions
How does the timing work?
Let's have a look at this config:
const config = {
fetchTimeout: 2000,
retryDelays: [100, 3000],
};
There are many situations that may occur (skipping happy path):
- request immediately failed with HTTP status code 500
- retries after 100ms
- request successful (we are done), OR fail again
- retries after 3000ms again for the last time
Example with timeouts:
- first request takes too long and it's terminated after 2000ms
- next retry was scheduled to be after 100ms but we already burned 2000ms so it's going to be executed immediately
- second request takes too long as well and is terminated after 2000ms
- last request will wait 3000ms minus the burned timeout => 1000ms
- last attempt (it will timeout or resolve correctly)
In reality, you can see some more optimistic scenarios: for example request failed with HTTP status code 500 and it's resolved immediately after you retry it (just some API glitch). Similar scenarios are quite often for timeouts: first try timeouted for some reason but it's OK when you try for the second time.
How do I mock this fetch?
One way how to mock this fetch is to use manual mock (src/__mocks__/@adeira/fetch.js
):
export default function fetchWithRetriesMock(resource: string) {
return Promise.resolve(`MODIFIED ${resource.toUpperCase()} 1`);
}
And then just use it:
import fetchWithRetriesMock from '@adeira/fetch';
jest.mock('@adeira/fetch');
it('mocks the fetch', async () => {
await expect(fetchWithRetriesMock('input')).resolves.toBe('MODIFIED INPUT 1');
});
Alternatively, you could inline the mock:
import fetchWithRetriesMock from '@adeira/fetch';
jest.mock('@adeira/fetch', () => {
return (resource) => Promise.resolve(`MODIFIED ${resource.toUpperCase()} 2`);
});
it('mocks the fetch', async () => {
await expect(fetchWithRetriesMock('input')).resolves.toBe('MODIFIED INPUT 2');
});
Why mocking global.fetch
doesn't work? It's because this fetch doesn't use or pollute global
variable: it uses ponyfill instead of polyfill behind the scenes.
Msw, an alternative to mock fetch
There is an alternative to mocking the fetch function. You can use msw
What it does is start a service worker (yes it works in node as well) and that service worker stops the network request and returns mocked data.
"I found MSW and was thrilled that not only could I still see the mocked responses in my DevTools, but that the mocks didn't have to be written in a Service Worker and could instead live alongside the rest of my app. This made it silly easy to adopt. The fact that I can use it for testing as well makes MSW a huge productivity booster."
– Kent C. Dodds