Client-Server Interaction

APIs and Error Handling


Learning Objectives

  • You know of error handling 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 “/1”, the API takes ten minutes to respond, while if the request is sent to the path “/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("/: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 to the /lib/apis folder with the name “errors-api.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}/${id}`);
  return await response.json();
};

export { getData };

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

<script>
  import { getData } from "$lib/apis/errors-api.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 /1</button>
<button onclick={() => fetchData(2)}>GET /2</button>
<button onclick={() => fetchData(3)}>GET /3</button>

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

Finally, to see the component, also the +page.svelte file in the routes folder 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 /1” and “GET /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”.

Finally, 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 to 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 errors-api.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}/${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”.

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 is a real error or not 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}/${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/errors-api.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 /1</button>
<button onclick={() => fetchData(2)}>GET /2</button>
<button onclick={() => fetchData(3)}>GET /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.

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 the request out if the request takes too long.

At the time of writing these materials, the timeout middleware is not available in Hono through JSR. We’ll add example code here once it is.

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. The timeout method 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 is added 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}/${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 that would take 60 seconds times 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}/${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 };

Generic error 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 fetchWithErrorHandling would work similarly to fetch, but it would handle the errors and timeouts.

const timeoutMs = 5000;

const fetchWithErrorHandling = async (url, options) => {
  try {
    const response = await fetch(url, {
      signal: AbortSignal.timeout(timeoutMs),
      ...options,
    });

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

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

    return { data: null, 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 fetchWithErrorHandling (or include code here)

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

export { getData };

Need for monitoring

In addition to handling errors, applications need to monitor and log errors. This way, one can see if there are recurring errors that need to be fixed. For this, there are a variety of tools such as Sentry.

Monitoring tools typically typically have an API endpoint to which errors are sent to from the application, be it the client or the server. The tools also commonly provide an user interface for analyzing the errors.