Why even simple window.fetch might be tricky
I recently saw this tweet https://twitter.com/thomasfindlay94/status/1672211922517622784:
While many responses were related to missed await
before fetch, I'd like to talk about a bigger issue: error handling.
Let's fix an obvious error and take a closer look at this code block:
const fetchData = async () => {
const response = await fetch('https://website.com');
const data = await response.json();
return data;
}
fetchData
returns a Promise
with a parsed JSON value (object) inside.
How many error-handling blocks do we need here? Would it be sufficient to have a single try-catch?
const fetchData = async () => {
try {
const response = await fetch('https://website.com');
const data = await response.json();
return data;
} catch (e) {
// Error reporting goes here
}
}
The short answer here is no, it's not the best possible design.
We have only 1 catch entry point, which should handle all the work:
e
can be:
Network issue:
Unstable connection, e.g. timeout
User goes offline
Other problems like CORS:
Parsing errors, when the received content is not valid JSON or not a JSON at all
That's the reason why sometimes engineers wrap every await
in try-catch
:
const fetchData = async () => {
let response; // We have to move response on top
try {
response = await fetch('https://website.com');
} catch (e) {
// Handle network errors
return errorState;
}
try {
const data = await response.json();
return data;
} catch (e) {
// Handle parsing/serialisation error
}
}
Additionally, when we return data
we cannot be sure of what would be the output. The request might get an error code response, as the server returns something different from 2xx:
Sometimes it is worth asking the user to re-login (e.g. 401)
Sometimes we should tell users they have no permission (403)
In some cases, validation might fail (400)
We might want to schedule a retry or ask user to try again later (5xx)
and etc.
And yes, the error code response would fulfill the original await
promise:
It would complicate our code furthermore:
const fetchData = async () => {
let response; // We have to move response on top
try {
response = await fetch('https://website.com');
} catch (e) {
// Handle network errors
return someErrorState;
}
// Request is successfull if any of the following conditions are true:
// response.ok === true
// response.status === 200
// response.status >= 200 && response.status < 300
if (!response.ok) {
switch (response.status) {
case 401: {
// Need re-login
return someErrorState;
}
...
default:
// Other errors
return someErrorState;
}
}
try {
const data = await response.json();
return data;
} catch (e) {
// Handle parsing/serialisation error
}
}
This code looks way more like spaghetti rather than something you can read, right?
But let's spend a little bit more time handling the last case: When a server returns an error code, it also can add a payload to identify an error exactly!
The most common usage for that is 400 error code during form validations or so. That means we should do response.json()
even when response.ok === false
. While doing so we should remember that response.json()
might reject the promise:
The most "correct" way is to verify content-type
header of the Response
before calling .json
or so. It's stored in Response.headers
field:
However:
It would over-complicate the code for the cases where we know for sure we expect
.json
format;There still can be a "corrupted" json.
Let's make it all together and start brushing up:
function parseResponseAsJson(response) {
try {
const data = await response.json();
return data;
} catch (e) {
// Handle parsing/serialisation error
}
}
const fetchData = async () => {
let response; // We have to move response on top
try {
response = await fetch('https://website.com');
} catch (e) {
// Handle network errors
return someErrorState;
}
// Request is successfull if any of the following conditions are true:
// response.ok === true
// response.status === 200
// response.status >= 200 && response.status < 300
if (!response.ok) {
switch (response.status) {
case 400: {
// Error, but we expect JSON with details
return parseResponseAsJson(response);
}
case 401: {
// Need re-login
return someErrorState;
}
...
default:
// Other errors
return someErrorState;
}
}
return parseResponseAsJson(response);
}
Overall we have a huge bunch of code just to make a simple request. That's insane!
How to make it simpler?
Let's start reducing the complexity of the code.
To do so, we should define the contract first.
We expect fetchData
to return some TResponse
from the server as a success. Additionally, we have a number of error states, which we need to support. Let's categorize them:
NetworkError
ParseError
400 error code
Login Error
ServerError
(Something else depending on a task)
For simplicity, we can say we expect only application/json
content type.
Here are the steps which we can make:
Create a clear contract for developers
Combine back 2
await
inside singletry-catch
block. To make it coorect we should distinguish, which line raised anError
. To do so, we should keepresponse
variable out oftry-catch
blockKeep "a good path" as short as possible and let all the error handling be placed either before or after "a good result"
Give the power to handle error codes to the developer, who would call our method. They should be able to have a
switch
statement on their side, butjson
results should be available to them.[Optional] Add simple typing
Let's implement it:
type ResponseResult<TResponse> =
| {
ok: true;
data: TResponse;
}
| {
ok: false;
errorCode: number;
// If you expect in your app particular type of errors,
// you can define errorData, or event specific pairs of
// errorCode+errorData
errorData: void | unknown;
}
| {
ok: false;
error: any;
};
export const fetchData = async <TResponse>(
url: string
): Promise<ResponseResult<TResponse>> => {
let response: void | Response; // We have to move response on top
try {
response = await fetch(url);
const parsed = await response.json();
if (response.ok) {
return {
ok: true,
data: parsed as TResponse
};
}
return {
ok: false,
errorCode: response.status,
errorData: parsed
};
} catch (e) {
// we should remember that response might fail due to Network issue
if (typeof response === "object") {
if (response.ok) {
return {
ok: false,
error: e
};
}
return {
ok: false,
errorCode: response.status,
errorData: null
};
}
return {
ok: false,
error: e
};
}
};
Sandbox: https://codesandbox.io/s/dank-hooks-6pxyqp
So... after several changes we finally did the version of fetch
which looks way better than at the very beginning, right?
We have error handling, parsing, response processing, and status code support, which should be sufficient for many common fetch
cases.
The journey isn't ended tho
We still owe:
fetch
configuration support. It is simple to do, we just need to extend function argumentsAbort
/AbortController
supportAdd support for data types different from
json
forReadableStream
.XSRF support
Better typing: e.g. better types for expected errors such as 4xx or so
etc.
And after a few more iterations we can get a utility library to handle requests for your project in the right way!
The alternative solution is to use a ready library: axios, superagent, ofetch or similar