Skip to content
aviral gupta

// I6.2 · ~40 min · Intermediate

HTTP errors, retries and timeouts

After this lesson your fetch code fails clearly: error statuses become errors, temporary failures are retried a few times, and slow requests stop after a timeout.

Lesson 2 of 5 in I6 Network requests and client storage

You will be able to

  • Check response.ok and throw an HttpError with the status, and handle a 404 differently from a 500
  • Retry a request a few times with a growing delay, only after a network error or a 5xx status
  • Stop a slow request with AbortSignal.timeout, and recognise TimeoutError and a body that is not JSON
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson I4.2. What does this print?

    class HttpError extends Error {
      constructor(status) {
        super("HTTP " + status);
        this.name = "HttpError";
        this.status = status;
      }
    }
    const error = new HttpError(404);
    console.log(error instanceof Error, error.name, error.status);
  2. Predict · Activity 2 of 7

    Predict before you read on. /broken answers 500 with the text Server error. What does this print?

    async function getJson(url) {
      const response = await fetch(url);
      return response.json();
    }
    try {
      console.log(await getJson(api + "/broken"));
    } catch (error) {
      console.log(error.name);
    }
  3. Practice · Activity 3 of 7

    Fill in the property that gives the HttpError the number of the status, such as 404.

    if (!response.ok) throw new HttpError(response.____);
    throw new HttpError(response.);
  4. Practice · Activity 4 of 7

    Match each failure to what your code should do.

  5. Practice · Activity 5 of 7

    /flaky answers 503 twice and then 200. What does this print?

    for (let attempt = 1; attempt <= 3; attempt++) {
      const response = await fetch(api + "/flaky");
      console.log(attempt, response.status);
      if (response.status < 500) break;
    }
  6. Brain teaser · Activity 6 of 7

    Brain teaser. /slow answers after 300 ms. What does this print?

    try {
      await fetch(api + "/slow", {signal: AbortSignal.timeout(50)});
      console.log("done");
    } catch (error) {
      console.log(error.name, error instanceof Error);
    }
  7. Apply · Activity 7 of 7

    Mini-task: with server.js from the worked example, write an HttpError class and getJson(url): it gives up after one second, throws an HttpError for a status that is not ok, and returns the parsed JSON. Load /books/1, /books/9 and /broken: print the title, "not found" for a 404, and "please try again later" for anything else.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A careful getJson with retries

server.js is a small test server with a working book, a missing one, a broken route (500), a busy route that answers 503 twice, an HTML page and a slow route. getJson turns error statuses into HttpErrors and gives up after a timeout. getJsonWithRetry tries again only after a network error or a 5xx status, at most three times, and waits longer each time.

main.js

import {startServer} from "./server.js";

class HttpError extends Error {
  constructor(status) {
    super(`HTTP ${status}`);
    this.name = "HttpError";
    this.status = status;
  }
}

async function getJson(url, ms = 1000) {
  const response = await fetch(url, {signal: AbortSignal.timeout(ms)});
  if (!response.ok) throw new HttpError(response.status);
  return response.json(); // can still reject with a SyntaxError
}

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

async function getJsonWithRetry(url, attempts = 3) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await getJson(url);
    } catch (error) {
      const temporary = error instanceof TypeError || (error instanceof HttpError && error.status >= 500);
      console.log(`  attempt ${attempt}: ${error.message}`);
      if (!temporary || attempt === attempts) throw error;
      await wait(50 * 2 ** (attempt - 1)); // 50 ms, then 100 ms, …
    }
  }
}

const server = await startServer();
const api = server.url;
for (const path of ["/books/1", "/books/9", "/broken", "/html"]) {
  try {
    console.log(path, (await getJson(api + path)).title);
  } catch (error) {
    console.log(path, error.name + ": " + error.message);
  }
}
try {
  await getJson(api + "/slow", 50);
} catch (error) {
  console.log("/slow", error.name);
}

console.log("/flaky", (await getJsonWithRetry(api + "/flaky")).title);
try {
  await getJsonWithRetry(api + "/books/9");
} catch (error) {
  console.log("gave up:", error.message);
}
console.log("requests to /flaky:", server.count("/flaky"));
await server.close();

server.js

// A small test server for this lesson, made with node:http. You do not need to change it.
import {createServer} from "node:http";

export async function startServer() {
  const counts = {}; // how often each path was requested
  const server = createServer((request, response) => {
    const path = request.url;
    counts[path] = (counts[path] ?? 0) + 1;
    const send = (status, type, text) => {
      response.writeHead(status, {"Content-Type": type});
      response.end(text);
    };
    const json = (status, value) => send(status, "application/json", JSON.stringify(value));
    if (path === "/books/1") json(200, {id: 1, title: "Dune"});
    else if (path === "/broken") send(500, "text/plain", "Server error");
    else if (path === "/flaky") {
      // busy twice (503), then it works
      if (counts[path] <= 2) send(503, "text/plain", "Busy");
      else json(200, {id: 2, title: "Emma"});
    } else if (path === "/html") send(200, "text/html", "<h1>Down for maintenance</h1>");
    else if (path === "/slow") {
      // answers after 300 ms, unless the client gave up
      const timer = setTimeout(() => json(200, {id: 3, title: "Kindred"}), 300);
      response.on("close", () => clearTimeout(timer));
    } else json(404, {error: "Not found"});
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
  return {
    url: `http://127.0.0.1:${server.address().port}`,
    count: (path) => counts[path] ?? 0,
    close: () => new Promise((resolve) => server.close(resolve))
  };
}

Run it with

node main.js

Output

/books/1 Dune
/books/9 HttpError: HTTP 404
/broken HttpError: HTTP 500
/html SyntaxError: Unexpected token '<', "<h1>Down f"... is not valid JSON
/slow TimeoutError
  attempt 1: HTTP 503
  attempt 2: HTTP 503
/flaky Emma
  attempt 1: HTTP 404
gave up: HTTP 404
requests to /flaky: 3
  • /html was ok (status 200), but its body was HTML, so json() rejected with a SyntaxError.
  • The busy route was tried three times and worked on the third; the 404 was tried once only.
  • The timeout rejected with TimeoutError after 50 ms, long before the slow answer would have come.

Exercises

Exercise 1 of 3

Error statuses become HttpErrors

getJson(url) should return the parsed JSON of an ok response, and reject with an HttpError (the class is given) for any other status, with the status in error.status. The starter parses every answer, so a 404 gives the server’s error object as if it were data, and a 500 gives a SyntaxError.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Run the tests: for /books/9 getJson gives no error at all. Which property of the response says it went wrong?

  2. Hint 2

    Check response.ok before you read the body.

  3. Hint 3

    if (!response.ok) throw new HttpError(response.status);

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export class HttpError extends Error {
  constructor(status) {
    super(`HTTP ${status}`);
    this.name = "HttpError";
    this.status = status;
  }
}

export async function getJson(url) {
  const response = await fetch(url);
  if (!response.ok) throw new HttpError(response.status);
  return response.json();
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export class HttpError extends Error {
  constructor(status) {
    super(`HTTP ${status}`);
    this.name = "HttpError";
    this.status = status;
  }
}

export async function getJson(url) {
  const response = await fetch(url);
  return response.json();
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {startServer} from './server.js';
import {HttpError, getJson} from './main.js';

const caught = (promise) => promise.then(() => null, (error) => error);

test('getJson gives book 1', async () => {
  const server = await startServer();
  try {
    const book = await getJson(server.url + '/books/1');
    assert.deepEqual(book, {id: 1, title: 'Dune'}, `getJson gave ${JSON.stringify(book)}`);
  } finally {
    await server.close();
  }
});

test('A 404 rejects with an HttpError with status 404', async () => {
  const server = await startServer();
  try {
    const error = await caught(getJson(server.url + '/books/9'));
    assert.ok(error instanceof HttpError, `getJson should reject with an HttpError, but it gave ${error ? error.name : 'no error'}`);
    assert.equal(error.status, 404, `error.status is ${error.status}`);
  } finally {
    await server.close();
  }
});

test('A 500 rejects with an HttpError with status 500', async () => {
  const server = await startServer();
  try {
    const error = await caught(getJson(server.url + '/broken'));
    assert.ok(error instanceof HttpError, `getJson should reject with an HttpError, but it gave ${error ? error.name : 'no error'}`);
    assert.equal(error.status, 500, `error.status is ${error.status}`);
  } finally {
    await server.close();
  }
});

server.js

// A small test server for this lesson, made with node:http. You do not need to change it.
import {createServer} from "node:http";

export async function startServer() {
  const counts = {}; // how often each path was requested
  const server = createServer((request, response) => {
    const path = request.url;
    counts[path] = (counts[path] ?? 0) + 1;
    const send = (status, type, text) => {
      response.writeHead(status, {"Content-Type": type});
      response.end(text);
    };
    const json = (status, value) => send(status, "application/json", JSON.stringify(value));
    if (path === "/books/1") json(200, {id: 1, title: "Dune"});
    else if (path === "/broken") send(500, "text/plain", "Server error");
    else if (path === "/flaky") {
      // busy twice (503), then it works
      if (counts[path] <= 2) send(503, "text/plain", "Busy");
      else json(200, {id: 2, title: "Emma"});
    } else if (path === "/html") send(200, "text/html", "<h1>Down for maintenance</h1>");
    else if (path === "/slow") {
      // answers after 300 ms, unless the client gave up
      const timer = setTimeout(() => json(200, {id: 3, title: "Kindred"}), 300);
      response.on("close", () => clearTimeout(timer));
    } else json(404, {error: "Not found"});
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
  return {
    url: `http://127.0.0.1:${server.address().port}`,
    count: (path) => counts[path] ?? 0,
    close: () => new Promise((resolve) => server.close(resolve))
  };
}

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 3

Retry only temporary failures

fetchWithRetry(url, attempts) tries up to attempts times and waits longer after each failure. It should retry only a network error (a TypeError) or an HttpError with a status of 500 or more; any other error must be thrown at once. The starter retries everything, so a missing book is requested three times.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Run the tests: the 404 was requested 3 times. Which errors can go away by themselves?

  2. Hint 2

    Decide in the catch block: a TypeError, or an HttpError whose status is 500 or more.

  3. Hint 3

    const temporary = error instanceof TypeError || (error instanceof HttpError && error.status >= 500); if (!temporary || attempt === attempts) throw error;

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export class HttpError extends Error {
  constructor(status) {
    super(`HTTP ${status}`);
    this.name = "HttpError";
    this.status = status;
  }
}

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

export async function fetchWithRetry(url, attempts = 3) {
  for (let attempt = 1; ; attempt++) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new HttpError(response.status);
      return await response.json();
    } catch (error) {
      const temporary = error instanceof TypeError || (error instanceof HttpError && error.status >= 500);
      if (!temporary || attempt === attempts) throw error;
      await wait(10 * 2 ** attempt);
    }
  }
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export class HttpError extends Error {
  constructor(status) {
    super(`HTTP ${status}`);
    this.name = "HttpError";
    this.status = status;
  }
}

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

export async function fetchWithRetry(url, attempts = 3) {
  for (let attempt = 1; ; attempt++) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new HttpError(response.status);
      return await response.json();
    } catch (error) {
      if (attempt === attempts) throw error;
      await wait(10 * 2 ** attempt);
    }
  }
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {startServer} from './server.js';
import {HttpError, fetchWithRetry} from './main.js';

const caught = (promise) => promise.then(() => null, (error) => error);

test('A busy server works on the third attempt', async () => {
  const server = await startServer();
  try {
    const book = await fetchWithRetry(server.url + '/flaky');
    assert.deepEqual(book, {id: 2, title: 'Emma'}, `fetchWithRetry gave ${JSON.stringify(book)}`);
    assert.equal(server.count('/flaky'), 3, `the server got ${server.count('/flaky')} requests`);
  } finally {
    await server.close();
  }
});

test('A 404 is not retried', async () => {
  const server = await startServer();
  try {
    const error = await caught(fetchWithRetry(server.url + '/books/9'));
    assert.ok(error instanceof HttpError && error.status === 404, 'fetchWithRetry should reject with an HttpError 404');
    assert.equal(server.count('/books/9'), 1, `a 404 was requested ${server.count('/books/9')} times instead of once`);
  } finally {
    await server.close();
  }
});

test('A broken server is tried three times, then the error is thrown', async () => {
  const server = await startServer();
  try {
    const error = await caught(fetchWithRetry(server.url + '/broken'));
    assert.ok(error instanceof HttpError && error.status === 500, 'fetchWithRetry should reject with an HttpError 500');
    assert.equal(server.count('/broken'), 3, `the server got ${server.count('/broken')} requests`);
  } finally {
    await server.close();
  }
});

server.js

// A small test server for this lesson, made with node:http. You do not need to change it.
import {createServer} from "node:http";

export async function startServer() {
  const counts = {}; // how often each path was requested
  const server = createServer((request, response) => {
    const path = request.url;
    counts[path] = (counts[path] ?? 0) + 1;
    const send = (status, type, text) => {
      response.writeHead(status, {"Content-Type": type});
      response.end(text);
    };
    const json = (status, value) => send(status, "application/json", JSON.stringify(value));
    if (path === "/books/1") json(200, {id: 1, title: "Dune"});
    else if (path === "/broken") send(500, "text/plain", "Server error");
    else if (path === "/flaky") {
      // busy twice (503), then it works
      if (counts[path] <= 2) send(503, "text/plain", "Busy");
      else json(200, {id: 2, title: "Emma"});
    } else if (path === "/html") send(200, "text/html", "<h1>Down for maintenance</h1>");
    else if (path === "/slow") {
      // answers after 300 ms, unless the client gave up
      const timer = setTimeout(() => json(200, {id: 3, title: "Kindred"}), 300);
      response.on("close", () => clearTimeout(timer));
    } else json(404, {error: "Not found"});
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
  return {
    url: `http://127.0.0.1:${server.address().port}`,
    count: (path) => counts[path] ?? 0,
    close: () => new Promise((resolve) => server.close(resolve))
  };
}

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 3 of 3

Give up after a timeout

getJsonWithin(url, ms) should return the parsed JSON, but give up when there is no response within ms milliseconds: its promise must then reject with a TimeoutError. The starter waits as long as the server takes. Pass a timeout signal to fetch.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Run the tests: the slow answer arrives after 300 ms instead of an error. Which option of fetch takes a signal?

  2. Hint 2

    AbortSignal.timeout(ms) makes a signal that aborts after ms milliseconds.

  3. Hint 3

    await fetch(url, {signal: AbortSignal.timeout(ms)})

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export async function getJsonWithin(url, ms) {
  const response = await fetch(url, {signal: AbortSignal.timeout(ms)});
  return response.json();
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export async function getJsonWithin(url, ms) {
  const response = await fetch(url);
  return response.json();
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {startServer} from './server.js';
import {getJsonWithin} from './main.js';

test('A fast answer arrives within a second', async () => {
  const server = await startServer();
  try {
    const book = await getJsonWithin(server.url + '/books/1', 1000);
    assert.deepEqual(book, {id: 1, title: 'Dune'}, `getJsonWithin gave ${JSON.stringify(book)}`);
  } finally {
    await server.close();
  }
});

test('A slow answer rejects with a TimeoutError after 50 ms', async () => {
  const server = await startServer();
  try {
    await assert.rejects(getJsonWithin(server.url + '/slow', 50), {name: 'TimeoutError'}, 'getJsonWithin should reject with a TimeoutError');
  } finally {
    await server.close();
  }
});

server.js

// A small test server for this lesson, made with node:http. You do not need to change it.
import {createServer} from "node:http";

export async function startServer() {
  const counts = {}; // how often each path was requested
  const server = createServer((request, response) => {
    const path = request.url;
    counts[path] = (counts[path] ?? 0) + 1;
    const send = (status, type, text) => {
      response.writeHead(status, {"Content-Type": type});
      response.end(text);
    };
    const json = (status, value) => send(status, "application/json", JSON.stringify(value));
    if (path === "/books/1") json(200, {id: 1, title: "Dune"});
    else if (path === "/broken") send(500, "text/plain", "Server error");
    else if (path === "/flaky") {
      // busy twice (503), then it works
      if (counts[path] <= 2) send(503, "text/plain", "Busy");
      else json(200, {id: 2, title: "Emma"});
    } else if (path === "/html") send(200, "text/html", "<h1>Down for maintenance</h1>");
    else if (path === "/slow") {
      // answers after 300 ms, unless the client gave up
      const timer = setTimeout(() => json(200, {id: 3, title: "Kindred"}), 300);
      response.on("close", () => clearTimeout(timer));
    } else json(404, {error: "Not found"});
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
  return {
    url: `http://127.0.0.1:${server.address().port}`,
    count: (path) => counts[path] ?? 0,
    close: () => new Promise((resolve) => server.close(resolve))
  };
}

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Expecting AbortError from a timeout

import {startServer} from "./server.js";

const server = await startServer();
try {
  await fetch(server.url + "/slow", {signal: AbortSignal.timeout(50)});
} catch (error) {
  if (error.name === "AbortError") console.log("Too slow, please try again.");
  else throw error;
} finally {
  await server.close();
}

What Node.js prints

DOMException [TimeoutError]: The operation was aborted due to timeout

Why, and the fix

A timeout signal does not reject with AbortError but with a DOMException named TimeoutError, so the check misses it and the error is thrown on. Check error.name === "TimeoutError" for a timeout; AbortError means that code called abort() on a controller.

An ok response that is not JSON

import {startServer} from "./server.js";

const server = await startServer();
try {
  const response = await fetch(server.url + "/html");
  if (response.ok) {
    const data = await response.json();
    console.log(data);
  }
} finally {
  await server.close();
}

What Node.js prints

SyntaxError: Unexpected token '<', "<h1>Down f"... is not valid JSON

Why, and the fix

The status was 200, but the body was an HTML page, as a server in maintenance or a proxy may send. ok says nothing about the format. Check response.headers.get("content-type") for application/json before json(), or catch the SyntaxError and report that the server sent something unexpected.

A retry loop that gives up silently

import {startServer} from "./server.js";

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

async function getJsonWithRetry(url, attempts = 3) {
  for (let attempt = 1; attempt <= attempts; attempt++) {
    try {
      const response = await fetch(url);
      if (response.ok) return response.json();
    } catch {
      // a network error: try again
    }
    await wait(10 * 2 ** attempt);
  }
}

const server = await startServer();
try {
  const book = await getJsonWithRetry(server.url + "/broken");
  console.log(book.title);
} finally {
  await server.close();
}

What Node.js prints

TypeError: Cannot read properties of undefined (reading 'title')

Why, and the fix

After the last attempt the loop simply ends, and the function returns undefined. The caller then fails far away from the real cause, a server error. Throw when the attempts are used up: keep the last error (an HttpError for a bad status) and throw it after the loop, so the caller knows that the request failed and why.

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Error statuses become errors

fetch fulfils for a 404 or a 500, so write one small helper that checks response.ok and throws when it is false. A class HttpError extends Error with a status field, as in lesson I4.2, lets callers decide by type and number instead of reading message texts: error instanceof HttpError && error.status === 404 means the thing does not exist, so show “not found”. A 500 means the server failed: show “please try again later”, or retry. Without the check, the error body of a 404 is treated as data, or json() fails on an error page with a confusing SyntaxError.

Retry only what can work next time

Some failures are temporary: a network error (TypeError: fetch failed) or a 5xx status such as 500, 502 or 503. Sending the same request again may work. A 4xx status says the request itself is wrong: a missing book stays missing, so never retry a 4xx. Limit the attempts, for example to 3, and throw the last error when they are used up, so the caller learns that it failed. Wait between attempts, and longer each time, such as 50, 100 and 200 ms, so a busy server gets time to recover.

Timeouts and bodies that are not JSON

fetch has no timeout option of its own. To stop waiting after a time you choose, pass signal: AbortSignal.timeout(5000). When the time is up, fetch rejects with a DOMException whose name is TimeoutError, which is an Error too. A response can be ok and still not be JSON, such as an HTML maintenance page: json() then rejects with a SyntaxError. Look at the content-type header first, or catch the SyntaxError and report “the server sent something unexpected” instead of crashing.

Sources

Last reviewed October 5, 2026