Skip to main content
ANVISoftware Solutions
Lesson 16 of 20Intermediate18 min

Calling APIs

By the end of this lesson

Fetch data, handle failures, and avoid unhandled rejections.

fetch makes an HTTP request and returns a promise. It is how the expense list gets its data and how a new claim is sent.

The mechanics take about five minutes to learn. What takes longer, and what separates a page that works on your machine from one that works for everyone, is handling the ways a request can go wrong — because it will, regularly.

A correct GET request
JavaScript
async function loadExpenses(employeeId) {
  const response = await fetch("/api/employees/" + employeeId + "/expenses");

  if (!response.ok) {
    throw new Error("Expenses request failed with status " + response.status);
  }

  return response.json();
}
  • fetch returns a promise that fulfils as soon as the response headers arrive. Awaiting it gives you a Response object, not your data.
  • response.ok is true for statuses in the 200s. The check on line 4 is not optional — see the callout below.
  • Including the status in the error message is worth the few extra characters. A 404 and a 503 need different responses from you, and without the number every failure looks the same in the logs.
  • response.json() reads the body and parses it, and it returns a promise too, because the body may still be arriving. Returning it from an async function is fine — the caller's await handles it.
  • json() also rejects when the body is not valid JSON, which is how an HTML error page from a proxy shows up: a parse error rather than an HTTP error.

What a Response gives you:

response.ok
True when the status is 200-299. The check that turns an HTTP error into a thrown error.
response.status
The number. Worth branching on: 401 means sign in again, 404 means it is gone, 500 means the server has a problem.
response.json()
Reads and parses the body as JSON. Returns a promise, and rejects if the body is not valid JSON.
response.text()
The body as a string. Useful for reading an error page or a non-JSON response.
response.headers.get(name)
One header, such as content-type. Handy for confirming you got the format you expected.
The second fetch argument
An options object: method, headers, body, signal, credentials. A POST needs method, a content-type header and a body of JSON.stringify(data).
The body can be read once
Calling json() after text() on the same response throws. Read it once and keep the result.

A request has four possible outcomes, and each is a state the interface has to show. Treating them as real states rather than as afterthoughts is most of what makes data-driven screens feel solid:

  • Loading — say something is happening. A skeleton or a spinner, and for anyone using a screen reader, a live region announcing that results are loading
  • Success with data — the normal case, and the only one most code handles
  • Success with nothing — a valid empty result. "No expenses this month" is information; a blank panel is indistinguishable from a broken one
  • Failure — a plain message about what could not be done, and a way to retry. "Could not load expenses" with a Retry button, not a stack trace and not silence
Cancelling a request that has been superseded
JavaScript
let currentSearch = null;

async function searchEmployees(term) {
  currentSearch?.abort();
  currentSearch = new AbortController();

  try {
    const response = await fetch(
      "/api/employees?q=" + encodeURIComponent(term),
      { signal: currentSearch.signal }
    );

    if (!response.ok) {
      throw new Error("Search failed with status " + response.status);
    }

    return await response.json();
  } catch (error) {
    if (error.name === "AbortError") {
      return null;   // replaced by a newer search; not a failure
    }

    throw error;
  }
}
  • Someone typing in a search box starts a request per keystroke. Responses can arrive out of order, so a slow result for "pri" can land after the result for "priya" and overwrite it with the wrong list.
  • An AbortController is a handle for cancelling. Passing its signal to fetch lets you stop that request later.
  • currentSearch?.abort() cancels the previous request if there was one. The ?. means "only if it is not null", so the first call does nothing.
  • An aborted fetch rejects with an error whose name is "AbortError". That is an expected outcome, not a problem, so it is filtered out before the real errors are re-thrown.
  • encodeURIComponent escapes the term so a name containing a space or an ampersand produces a valid URL rather than a broken query.
  • Aborting also frees the connection. On a slow network that is the difference between one useful request and fifteen competing ones.

Summary

  • fetch rejects only when no response arrives — a 404 or 500 fulfils, so check response.ok and throw
  • Wrap fetch once so the status check and JSON parsing cannot be forgotten at a call site
  • Loading, data, empty and error are four real interface states, and empty is not an error
  • AbortController cancels superseded requests and removes out-of-order result races
  • Every request path needs a catch, and handling means clearing the loading state and telling the reader what failed

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Write the wrapper you will actually use

Write a small function that every request in the application goes through. It should check the status, throw a useful error that includes the status, parse JSON, and let callers pass fetch options.

Then use it in a function that loads expenses and drives all four interface states.

Show solution

One wrapper is the point. The ok check is the thing most likely to be forgotten, so putting it somewhere it cannot be forgotten is worth more than being careful at each call site.

Carrying the status on the error lets callers make decisions without parsing a message. A 401 might send someone to sign in; a 404 might show "this claim no longer exists"; everything else gets the generic message.

The empty result is handled separately from the error, because they are different things and the reader needs to be able to tell them apart.

finally turns the loading state off on every path. That is what stops a failed request leaving a spinner on screen, which is the most common visible symptom of sloppy error handling.

JavaScript
class ApiError extends Error {
  constructor(message, status) {
    super(message);
    this.name = "ApiError";
    this.status = status;
  }
}

async function requestJson(url, options) {
  const response = await fetch(url, options);

  if (!response.ok) {
    throw new ApiError("Request to " + url + " failed", response.status);
  }

  return response.json();
}

async function showExpenses(employeeId, signal) {
  setState("loading");

  try {
    const expenses = await requestJson(
      "/api/employees/" + employeeId + "/expenses",
      { signal }
    );

    if (expenses.length === 0) {
      setState("empty", "No expenses claimed this month.");
      return;
    }

    setState("ready");
    renderExpenses(expenses);
  } catch (error) {
    if (error.name === "AbortError") {
      return;
    }

    if (error.status === 401) {
      setState("error", "Your session has expired. Sign in again.");
    } else {
      setState("error", "Could not load expenses.");
    }

    console.error(error);
  } finally {
    setLoadingIndicator(false);
  }
}

Think about it

Think about it

An expense panel shows an empty list for one employee. The network panel shows the request returned 500. The code has no error handling and nothing appears in the console beyond one warning. Explain the full chain, and say which single change would have made the problem obvious.

Show solution

fetch fulfilled, because a response arrived. Without an ok check the code went straight to response.json(), which either parsed the server's error body or rejected. Either way nothing threw in a place anyone was watching, so the render ran with no expenses and produced an empty list.

The one change is checking response.ok and throwing. That converts a silent wrong result into a real error, which then reaches a catch and can be shown to the reader.

It is worth being clear about what the empty list cost. Nobody knew there was a server problem — the employee assumed their claims had been lost. A visible "could not load" message would have been reported on the first day instead of the tenth.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

The server returns 500. What does the promise from fetch do?
What problem does AbortController solve in a search-as-you-type field?
Why is an unhandled promise rejection worse than a thrown error the reader can see?

Saved in this browser only.