Forms and Client-Server Interaction

APIs and Error Handling


Learning Objectives

  • You know how to handle errors when working with APIs.

Whenever working with APIs, or any sort of systems, there’s a possibility of errors. These errors can be due to a variety of reasons, such as invalid data, network issues, or server issues. Here, we briefly look into handling errors when working with APIs.

Setup for examples

In the next examples, we use the following API endpoint. If the request is sent to the path “/api/errors/1”, the API takes ten minutes to respond, while if the request is sent to the path “/api/errors/2”, there’s an error on the server, which leads to a default error response from the server handled by Hono. Otherwise, the response contains a JSON document with the text “Hello”, followed by the id from the path variable, as the value for a property called message.

app.get("/api/errors/:id", async (c) => {
  const id = c.req.param("id");
  if (id === "1") {
    await new Promise((res) => setTimeout(res, 10 * 60 * 1000));
  } else if (id === "2") {
    throw new Error("Oops, something failed on the server.");
  }

  return c.json({ message: `Hello ${id}` });
});

Further, the API is accessed on the client through a file that separates API calls from components. The following is placed in the src/lib/apis folder with the name “errorsApi.js”; the PUBLIC_API_URL corresponds to the address of the server.

import { PUBLIC_API_URL } from "$env/static/public";

const getData = async (id) => {
  const response = await fetch(`${PUBLIC_API_URL}/api/errors/${id}`);
  return await response.json();
};

export { getData };

The above file is used in the following component called Errors.svelte, which is placed in the folder src/lib/components. The component has three buttons, each used for requesting data from the API.

<script>
  import { getData } from "$lib/apis/errorsApi.js";

  let message = $state({});
  let loading = $state(false);

  const fetchData = async (id) => {
    loading = true;
    message = await getData(id);
    loading = false;
  };
</script>

{#if loading}
  <p>Loading...</p>
{/if}

<button onclick={() => fetchData(1)}>GET /api/errors/1</button>
<button onclick={() => fetchData(2)}>GET /api/errors/2</button>
<button onclick={() => fetchData(3)}>GET /api/errors/3</button>

<p>{JSON.stringify(message)}</p>

Finally, to see the component, also update the src/routes/+page.svelte file to match the following.

<script>
  import Errors from "$lib/components/Errors.svelte";
</script>

<Errors />

When you try out the component locally, you’ll notice that the button with the text “GET /3” works, while the two other buttons do not. Pressing the buttons “GET /api/errors/1” and “GET /api/errors/2” lead to the text “Loading…” being shown.

Handling errors

Retrieving data from the server can fail due to issues like network problems, which leads to an error thrown from the fetch request. The server can also fail to process the request, in which case the server will return a response that indicates that there was an error.

Hono has a default error handler that, in the case of an error, responds with the status code 500 and the message “Internal Server Error”.

Loading Exercise...

There is also the possibility that the processing of the response fails, e.g., if the response does not contain a JSON document although the code tries to parse the response to one.

To handle these cases, we need to wrap the fetch request in a try-catch block, where we cover for the errors, and where we also check whether the response status indicates that everything went well.

In the following example, we’ve changed the errorsApi.js to (1) check if the status code of the response indicates a successful request (with the property ok of the response), and to (2) parse the response within the try block. If parsing the response fails, or fetching the data fails, the error is caught and processed in the catch block.

import { PUBLIC_API_URL } from "$env/static/public";

const getData = async (id) => {
  try {
    const response = await fetch(`${PUBLIC_API_URL}/api/errors/${id}`);
    if (!response.ok) {
      return { error: response.statusText };
    }

    return await response.json();
  } catch (error) {
    return { error: error.message };
  }
};

export { getData };

Now, the application shows an object with the property error with the value “Internal Server Error” if there is an error during the request. If you change the address from which the data is being retrieved from to something that does not exist, you’ll also see an error such as “Failed to fetch”.

Loading Exercise...

Separating response and error

The above approach can be problematic, as the function returns an object regardless of whether there was an error or not. As an example, if the API is supposed to return a JSON document with the property error, checking whether it’s a real error becomes tricky.

From the point of view of the component using the getData function, it might be better if errors would be explicitly separated from the result.

An alternative approach is to separate the response from the error, and to return an object with two properties, data and error. By structuring the returned object into two properties, identifying whether an error occurred or not is easier. With this approach, the API-related code could be modified as follows.

import { PUBLIC_API_URL } from "$env/static/public";

const getData = async (id) => {
  try {
    const response = await fetch(`${PUBLIC_API_URL}/api/errors/${id}`);
    if (!response.ok) {
      return { data: null, error: response.statusText };
    }

    const data = await response.json();
    return { data, error: null };
  } catch (error) {
    return { data: null, error: error.message };
  }
};

export { getData };

To accommodate the change, the Errors.svelte component would also be changed to do something in the case of an error. In the following, a separate “Oh noes!” message with the error is shown.

<script>
  import { getData } from "$lib/apis/errorsApi.js";

  let response = $state({});
  let loading = $state(false);

  const fetchData = async (id) => {
    loading = true;
    response = await getData(id);
    loading = false;
  };
</script>

{#if loading}
  <p>Loading...</p>
{/if}

<button onclick={() => fetchData(1)}>GET /api/errors/1</button>
<button onclick={() => fetchData(2)}>GET /api/errors/2</button>
<button onclick={() => fetchData(3)}>GET /api/errors/3</button>

<p>{JSON.stringify(response)}</p>

{#if response.error}
  <p>Oh noes! Error: {response.error}</p>
{:else}
  <p>{response?.data?.message}</p>
{/if}

Later on, when looking into styling, we’ll also briefly look into how to create a “toast” that pops up on the screen for a brief moment. Such a toast can also be used to briefly display errors.

Loading Exercise...

Handling timeouts

At this point, the system handles errors, but does not do anything with long requests. Timeouts can be handled both on the client and the server. For example, on the server, we could use Hono’s timeout middleware to time out a request if it takes too long.

On the client, timeouts can be handled with the timeout method of the AbortSignal API. The AbortSignal API provides a static method timeout that returns an abort signal after a specific time. When used with the fetch API, the timeout can be given as an option to the fetch request. If the request takes longer than the set timeout, the request is cancelled and an error is thrown.

The following modification shows how the timeout signal is added to the fetch method.

import { PUBLIC_API_URL } from "$env/static/public";

const timeoutMs = 5000;

const getData = async (id) => {
  try {
    const response = await fetch(`${PUBLIC_API_URL}/api/errors/${id}`, {
      signal: AbortSignal.timeout(timeoutMs),
    });

    if (!response.ok) {
      return { data: null, error: response.statusText };
    }

    const data = await response.json();
    return { data, error: null };
  } catch (error) {
    return { data: null, error: error.message };
  }
};

export { getData };

With the above change, the request to the API endpoint with a timeout would time out after 5 seconds, and the user is shown the message “Oh noes! Error: signal timed out”.

Naturally, one could also modify the error messages to be more informative. As an example, the following adjustment to the above call would lead to a situation, where the shown error would be “Oh noes! Error: Timeout — the request took too long.”

import { PUBLIC_API_URL } from "$env/static/public";

const timeoutMs = 5000;

const getData = async (id) => {
  try {
    const response = await fetch(`${PUBLIC_API_URL}/api/errors/${id}`, {
      signal: AbortSignal.timeout(timeoutMs),
    });

    if (!response.ok) {
      return { data: null, error: response.statusText };
    }

    const data = await response.json();
    return { data, error: null };
  } catch (error) {
    let message = error.message;
    if (error?.name === "TimeoutError") {
      message = "Timeout -- the request took too long.";
    }

    return { data: null, error: message };
  }
};

export { getData };
Loading Exercise...

Retry with timeout

In some cases, it might be useful to retry a request if it times out. The following modification to the above code would retry the request when a timeout occurs.

import { PUBLIC_API_URL } from "$env/static/public";

const timeoutMs = 5000;

const getData = async (id) => {
  let attempts = 0;
  const maxAttempts = 2;

  while (attempts < maxAttempts) {
    try {
      const response = await fetch(`${PUBLIC_API_URL}/api/errors/${id}`, {
        signal: AbortSignal.timeout(timeoutMs),
      });

      if (!response.ok) {
        return { data: null, error: response.statusText };
      }

      const data = await response.json();
      return { data, error: null };
    } catch (error) {
      if (error?.name === "TimeoutError") {
        attempts += 1;
        if (attempts >= maxAttempts) {
          return { data: null, error: "Timeout -- the request took too long." };
        }
      } else {
        return { data: null, error: error.message };
      }
    }
  }
};

export { getData };

Generic wrapper

In the above example, we built functionality for handling errors and timeouts in API requests. We could also generalize the above approach, and create our own function for the task. The following function myFetch would work similarly to fetch, but it would handle the errors and timeouts, retrying up to two times if the request times out.

const myFetch = async (url, options = {}, timeoutMs = 5000) => {
  let attempts = 0;
  const maxAttempts = 2;

  while (attempts < maxAttempts) {
    try {
      const response = await fetch(url, {
        ...options,
        signal: AbortSignal.timeout(timeoutMs),
      });

      if (!response.ok) {
        return { data: null, error: response.statusText };
      }

      const data = await response.json();
      return { data, error: null };
    } catch (error) {
      if (error?.name === "TimeoutError") {
        attempts += 1;
        if (attempts >= maxAttempts) {
          return { data: null, error: "Timeout -- the request took too long." };
        }
      } else {
        return { data: null, error: error.message };
      }
    }
  }
};

With the above in place, the implementation of the earlier getData function would become considerably cleaner, as shown below.

import { PUBLIC_API_URL } from "$env/static/public";

// import myFetch (or include code here)

const getData = async (id) => {
  return await myFetch(`${PUBLIC_API_URL}/api/errors/${id}`);
};

export { getData };
Loading Exercise...

Summary

In summary:

  • When working with APIs, there is always a possibility of errors: errors can be due to network issues, server issues, or issues with processing the response.
  • Many errors can be handled with try-catch blocks and by checking the status of the response.
  • Timeouts can be handled both on the client and on the server — both can time out requests that take too long.
  • Requests that time out or fail due to network issues can be retried.
  • Wrapping the fetch method into a custom function can help in reusing error and timeout handling code.