Fetch and APIs

Call an HTTP API and handle every way it can fail.

23of 32
≈ 13Min read
9Parts

The shape of a request

GET and POST
const res = await fetch("/api/doors");

const res = await fetch("/api/doors", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Painted", days: 5 }),
  signal: controller.signal,
  credentials: "same-origin",     // "include" to send cookies cross-origin
  cache: "no-store",
});

The trap: fetch does not reject on 404

the check people skip
// fetch only rejects on a NETWORK failure — DNS, offline, CORS, abort.
// A 404 or a 500 is a perfectly successful HTTP conversation.

const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const data = await res.json();
This is why data silently becomes undefined

Without the res.ok check, a 404 whose body is an HTML error page makes res.json() throw a confusing parse error instead of telling you the request failed.

Reading the body

and you may only read it once
await res.json();        // parses — throws if the body is not JSON
await res.text();
await res.blob();        // an image or a file
await res.formData();
await res.arrayBuffer();

res.status;              // 200
res.ok;                  // status in 200–299
res.headers.get("content-type");
res.url; res.redirected;

// the body is a stream: reading it twice throws
const copy = res.clone();   // if you really need to

A wrapper worth writing once

Reading text() first and parsing yourself handles the two cases res.json() cannot: an empty body, and an error page that is not JSON.

every call in an app goes through this
class HttpError extends Error {
  constructor(status, body) {
    super(`HTTP ${status}`);
    this.name = "HttpError";
    this.status = status;
    this.body = body;
  }
}

async function api(path, { method = "GET", body, signal } = {}) {
  const res = await fetch(path, {
    method,
    signal,
    headers: body ? { "Content-Type": "application/json" } : undefined,
    body: body ? JSON.stringify(body) : undefined,
  });

  const text = await res.text();
  const data = text ? JSON.parse(text) : null;      // 204 has no body

  if (!res.ok) throw new HttpError(res.status, data);
  return data;
}

Cancelling

and why the abort is not an error
let controller;

async function search(q) {
  controller?.abort();                  // cancel the one in flight
  controller = new AbortController();

  try {
    return await api(`/search?q=${encodeURIComponent(q)}`, { signal: controller.signal });
  } catch (err) {
    if (err.name === "AbortError") return null;     // expected
    throw err;
  }
}

// a timeout, without writing the plumbing
fetch(url, { signal: AbortSignal.timeout(5000) });
Always encode what you interpolate

encodeURIComponent on anything a person typed. A query containing & or # silently truncates the request otherwise.

Query strings

build them, do not concatenate them
const url = new URL("/api/doors", location.origin);
url.searchParams.set("page", 2);
url.searchParams.set("q", userText);      // encoded for you

await fetch(url);

CORS, in one paragraph

A request to a different origin is only readable by your page if that server sends Access-Control-Allow-Origin. The browser makes the request either way; it just refuses to give you the response.

You cannot fix a CORS error in your front-end code. It is fixed on the server, or by proxying the request through your own backend. A "no-cors" mode exists and gives you an opaque response you cannot read — it is not a workaround.

Uploading

do not set the content type yourself
const fd = new FormData(form);
fd.append("plan", file);

await fetch("/upload", { method: "POST", body: fd });
// the browser sets multipart/form-data WITH the boundary; setting it
// by hand omits the boundary and the server cannot parse the body

Loading, error, empty

Every request has four states and an interface has to show all of them: loading, success, empty success, and failure. The one that gets forgotten is empty success — the request worked and there are no results, which is not an error and must not look like one.

the shape
async function load() {
  setState({ status: "loading" });
  try {
    const doors = await api("/api/doors");
    setState({ status: doors.length ? "ready" : "empty", doors });
  } catch (err) {
    setState({ status: "error", message: err.message });
  }
}

Practice