IntermediateJavaScript · Lesson 6 of 10

Working with APIs: POST, Headers & Errors

Send JSON with fetch, add auth headers, handle HTTP errors and timeouts, and cancel requests with AbortController.

Reading data is only half of using an API. To create something, call fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }). Protected APIs usually expect a token in the Authorization: Bearer <token> header. The small server below uses only Node's built-in http module, so you can practise against it locally (node server.js, Node 18 or newer).

fetch only rejects when the request could not be made at all (offline, DNS failure, cancelled). A 401, 404, 422 or 500 response still resolves, so always check res.ok or res.status. Wrapping fetch in one request() helper means every call gets the same headers, JSON handling and errors: a custom ApiError that carries the status.

Never wait forever: AbortSignal.timeout(ms) cancels a request that takes too long, and an AbortController lets you cancel on demand, for example when the user types a new search before the old one finished. A cancelled fetch rejects with an AbortError (or TimeoutError), which you usually ignore rather than show. In a browser page calling a different origin, the server must also send CORS headers.

package.jsonJSON
{
  "name": "students-api-demo",
  "type": "module",
  "private": true
}
server.jsJavaScript
// A tiny practice API using only Node's built-in http module: node server.js
import { createServer } from "node:http";

const students = [{ id: 1, name: "Amina Hassan", form: 4 }];
let flakyCalls = 0;

function send(res, status, body) {
  res.writeHead(status, { "Content-Type": "application/json" });
  res.end(JSON.stringify(body));
}

async function readJson(req) {
  let text = "";
  for await (const chunk of req) text += chunk;
  return JSON.parse(text || "{}");
}

createServer(async (req, res) => {
  if (req.headers.authorization !== "Bearer demo-token") return send(res, 401, { error: "Missing or wrong token" });

  if (req.method === "GET" && req.url === "/students") return send(res, 200, students);

  if (req.method === "POST" && req.url === "/students") {
    let body;
    try {
      body = await readJson(req);
    } catch {
      return send(res, 400, { error: "Body must be JSON" });
    }
    if (typeof body.name !== "string" || body.name.trim().length < 2) return send(res, 422, { error: "name is required" });
    const student = { id: students.length + 1, name: body.name.trim(), form: Number(body.form) || 1 };
    students.push(student);
    return send(res, 201, student);
  }

  if (req.url === "/slow") return setTimeout(() => send(res, 200, { ok: true }), 3000);

  // Fails twice with 503, then succeeds: used in the exercise.
  if (req.url === "/flaky") return ++flakyCalls % 3 === 0 ? send(res, 200, { ok: true, attempt: flakyCalls }) : send(res, 503, { error: "Try again" });

  send(res, 404, { error: "Not found" });
}).listen(3000, () => console.log("API on http://localhost:3000"));
api.jsJavaScript
export class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.name = "ApiError";
    this.status = status;
  }
}

const BASE_URL = "http://localhost:3000";

export async function request(path, { method = "GET", body, token, timeoutMs = 2000 } = {}) {
  const headers = { Accept: "application/json" };
  if (body !== undefined) headers["Content-Type"] = "application/json";
  if (token) headers.Authorization = `Bearer ${token}`;

  let res;
  try {
    res = await fetch(BASE_URL + path, {
      method,
      headers,
      body: body === undefined ? undefined : JSON.stringify(body),
      signal: AbortSignal.timeout(timeoutMs),       // gives up if the server is too slow
    });
  } catch (err) {
    if (err.name === "TimeoutError") throw new ApiError(0, `Timed out after ${timeoutMs} ms`);
    throw new ApiError(0, `Network error: ${err.message}`);
  }

  const data = res.headers.get("Content-Type")?.includes("application/json") ? await res.json() : null;
  if (!res.ok) throw new ApiError(res.status, data?.error ?? res.statusText);   // fetch does NOT throw on 4xx/5xx
  return data;
}
client.jsJavaScript
// Start the server in one terminal (node server.js), then run: node client.js
import { ApiError, request } from "./api.js";

const token = "demo-token";

const created = await request("/students", { method: "POST", body: { name: "Juma Said", form: 3 }, token });
console.log("Created:", created);

console.log("All:", await request("/students", { token }));

for (const attempt of [
  () => request("/students", { method: "POST", body: { name: "" }, token }),
  () => request("/students"),
  () => request("/slow", { token, timeoutMs: 500 }),
]) {
  try {
    await attempt();
  } catch (err) {
    if (err instanceof ApiError) console.log(`Failed (${err.status}): ${err.message}`);
    else throw err;
  }
}

// Cancel on demand: e.g. the user typed again before the last search finished
const controller = new AbortController();
const pending = fetch("http://localhost:3000/slow", { headers: { Authorization: `Bearer ${token}` }, signal: controller.signal });
controller.abort();
await pending.catch((err) => console.log("Cancelled:", err.name));

Key points

  • Send JSON with method, a Content-Type header and JSON.stringify(body).
  • fetch does not throw on HTTP errors: check res.ok and raise your own error with the status.
  • Use AbortSignal.timeout and AbortController so requests can time out or be cancelled.

Exercise

Write requestWithRetry(path, options, { retries, baseDelayMs }) on top of request(). It retries network errors, timeouts and 5xx responses with exponential backoff (200, 400, 800 ms...), but never retries 4xx errors. Test it against the server's /flaky route, which fails twice before succeeding.

Show solution

Try the exercise yourself first — then compare your approach with this one.

The loop calls request() and only retries when the error could be temporary: status 0 (network error or timeout) or 5xx. A 422 is the client's own mistake, so retrying would just fail again. The delay doubles each time (exponential backoff) so a struggling server gets room to recover. Put retry.js next to api.js and package.json from the lesson.

retry.jsJavaScript
import { ApiError, request } from "./api.js";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Retry only failures that might succeed later: network errors, timeouts (status 0) and 5xx.
async function requestWithRetry(path, options = {}, { retries = 3, baseDelayMs = 200 } = {}) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await request(path, options);
    } catch (err) {
      const retryable = err instanceof ApiError && (err.status === 0 || err.status >= 500);
      if (!retryable || attempt > retries) throw err;
      const delay = baseDelayMs * 2 ** (attempt - 1);          // 200, 400, 800 ms ...
      console.log(`attempt ${attempt} failed (${err.status}), retrying in ${delay} ms`);
      await sleep(delay);
    }
  }
}

// With the server running: node retry.js
const token = "demo-token";
console.log(await requestWithRetry("/flaky", { token }));
try {
  await requestWithRetry("/students", { method: "POST", body: { name: "" }, token });
} catch (err) {
  console.log(`not retried: ${err.status} ${err.message}`);
}

Check your understanding

  1. A POST returns status 422. What does await fetch(...) do?

  2. Which header tells the server the body is JSON?

  3. How do you stop a fetch that is taking longer than 2 seconds?

  4. Which failure should a retry helper NOT retry?

Ask AI