Why even simple window.fetch might be tricky

·

6 min read

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:

  1. Network issue:

    1. Unstable connection, e.g. timeout

    2. User goes offline

    3. Other problems like CORS:

      Example of CORS error

    4. 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:

  1. Sometimes it is worth asking the user to re-login (e.g. 401)

  2. Sometimes we should tell users they have no permission (403)

  3. In some cases, validation might fail (400)

  4. We might want to schedule a retry or ask user to try again later (5xx)

  5. 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:

  1. It would over-complicate the code for the cases where we know for sure we expect .json format;

  2. 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:

  1. NetworkError

  2. ParseError

  3. 400 error code

  4. Login Error

  5. ServerError

  6. (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:

  1. Create a clear contract for developers

  2. Combine back 2 await inside single try-catch block. To make it coorect we should distinguish, which line raised an Error. To do so, we should keep response variable out of try-catch block

  3. Keep "a good path" as short as possible and let all the error handling be placed either before or after "a good result"

  4. 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, but json results should be available to them.

  5. [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:

  1. fetch configuration support. It is simple to do, we just need to extend function arguments

  2. Abort / AbortController support

  3. Add support for data types different from json for ReadableStream.

  4. XSRF support

  5. Better typing: e.g. better types for expected errors such as 4xx or so

  6. 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