Skip to content
academia.sh

Lesson 12 / 22

REST Client

The request layer as the application's single gateway for talking to the server — the four classes of the error contract, mapping status codes to errors, cancellation and timeouts, and the design of resource addresses.

Contents

The State Management topic settled where the application keeps which information and how that information survives after the page closes. The container is ready: local state sits inside the component, shared state in a common store, the state carried in the address in the route, and whatever needs to persist in storage. But the largest part of that container is still empty — server state.

In the North Slope Measurement Station interface, the station list, each station’s measurement history, and new measurement records are all held on the server. This lesson covers the contract by which that data is fetched: who builds the request, how the response is resolved, and what the view is told when something goes wrong. The answer to that question is not a function but a layer.

Why the Request Layer Is a Separate Tier

When the code that fetches data is written directly inside a component, four things get repeated at every call site: assembling the base address, attaching common headers, resolving the response, and interpreting the error. The repetition is not only a cost in typing; each repetition produces its own behavior. In one place a 404 counts as an empty list, in another it counts as an error; one place has a timeout, another does not.

The request layer is a thin intermediary that gathers these four responsibilities into one place. It makes the view two promises: every call either returns data or an error object conforming to the error contract — no third option. Below the layer sits HTTP, above it sit application concepts. The code that asks for the station list does not know about status codes; it only sees “not found” or “no access.”

The layer’s boundary is just as clear. The request layer does not hold a cache, does not retry, and does not manage a loading indicator. Those are, respectively, the jobs of server state management, the resilience scheme, and the view state machine; later lessons build each of them separately. The layer’s only job is to turn the network’s uncertainty into a closed, countable set of outcomes.

The Error Contract

A request can fail in four distinct places, and the four are not the same thing.

  • Network error. The request never reached the other side: the name could not be resolved, the connection could not be established, the connection dropped. The server may not have seen this request — but it may have; the response getting lost on the way back looks like the same error. This uncertainty will be decisive in the retry decision.
  • HTTP error. The request arrived, the server answered, and the response reports failure. What the server thinks is written in the status code, and this information is reliable.
  • Parse error. The response arrived, the status code says success, but the body is not in the expected shape: malformed JSON, a missing field, a different content type. An intervening proxy returning an HTML error page falls into this class.
  • Cancellation. The request was stopped because nobody is waiting for the result anymore. The user left the page, typed another letter into the search box, or the timeout expired. This is not a failure but a decision; no error is shown to the user.

The four need a shared representation, or every call site reinvents its own distinction. The contract consists of these fields: type (one of the four classes), code (a constant name the application recognizes), status (the HTTP status code, if any), retryable (boolean), and detail (the structured explanation the server sent). The message text is not kept in the contract — which language and tone it is written in is the view’s decision; the Internationalization lesson closes that gap.

Mapping Status Codes to Errors

Mapping is a table’s job, and the table lives in a single place. The status code classes introduced in the How the Internet Works course’s HTTP Request and Response lesson turn into an application decision here: 4xx says the problem is with the request itself, and repeating the same request does not change the outcome; 5xx says the server could not complete it at that moment, and repeating it may work.

// status-mapping.mjs — from HTTP status code to the error contract
const MAPPING = [
  [400, "malformed_request", false, "Request body could not be read"],
  [401, "no_auth",           false, "Session required"],
  [403, "no_permission",     false, "No access to this record"],
  [404, "not_found",         false, "Record not found"],
  [409, "conflict",          false, "Record changed in the meantime"],
  [422, "validation",        false, "Field values were rejected"],
  [429, "rate_limit",        true,  "Request rate exceeded"],
  [500, "server",            true,  "Server could not complete the request"],
  [503, "unavailable",       true,  "Service temporarily unavailable"],
];

function httpError(status, detail = null) {
  const row = MAPPING.find(([s]) => s === status);
  if (row) {
    const [, code, retryable, message] = row;
    return { type: "http", status, code, message, retryable, detail };
  }
  // Unknown code: the decision follows its class, no code is invented.
  const serverClass = status >= 500;
  return {
    type: "http",
    status,
    code: serverClass ? "server" : "request",
    message: serverClass ? "Server could not complete the request" : "Request was not accepted",
    retryable: serverClass,
    detail,
  };
}

for (const status of [401, 404, 422, 429, 500, 418, 504]) {
  const h = httpError(status);
  console.log(
    String(h.status).padEnd(4),
    h.code.padEnd(18),
    "retry:", String(h.retryable).padEnd(5),
    h.message,
  );
}
401  no_auth            retry: false Session required
404  not_found          retry: false Record not found
422  validation         retry: false Field values were rejected
429  rate_limit         retry: true  Request rate exceeded
500  server             retry: true  Server could not complete the request
418  request            retry: false Request was not accepted
504  server             retry: true  Server could not complete the request

The last two rows show the table’s real value. Codes missing from the table are still resolved to a decision that follows their class’s meaning; the layer does not collapse with “unknown code” and leave the view in an undefined state. 429 is an interesting exception: it is in the 4xx class but retryable, because the problem lies not in the request’s content but in its timing.

The Layer, End to End

The file below both sets up a local server and runs the request layer against it; it runs on its own and closes the server at the end. Because the port is given as zero, the operating system picks a free one — that number varies by the machine running it and does not appear in the output.

// request-layer.mjs — local server + request layer, in a single file
import http from "node:http";

// --- Server: station list, measurement history, and error routes -----------
const STATIONS = [
  { code: "NS-01", name: "North Slope", elevation: 1840 },
  { code: "NS-02", name: "North Slope Ridge", elevation: 2110 },
];

const server = http.createServer((req, res) => {
  const url = new URL(req.url, "http://127.0.0.1");
  const send = (status, body, type = "application/json") => {
    res.writeHead(status, { "content-type": type });
    res.end(typeof body === "string" ? body : JSON.stringify(body));
  };
  if (url.pathname === "/stations") return send(200, STATIONS);
  if (url.pathname === "/stations/NS-99")
    return send(404, { code: "station_not_found", message: "NS-99 is not registered" });
  if (url.pathname === "/measurements" && req.method === "POST")
    return send(422, { code: "validation", fields: { value: "out_of_range" } });
  if (url.pathname === "/report")
    return send(500, "<html>server error</html>", "text/html");
  if (url.pathname === "/slow")
    return setTimeout(() => send(200, { ready: true }), 300);
  return send(404, { code: "no_route", message: "Unknown path" });
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const BASE = `http://127.0.0.1:${server.address().port}`;

// --- Error contract -----------------------------------------------------
const MAPPING = new Map([
  [401, ["no_auth", false]],       [403, ["no_permission", false]],
  [404, ["not_found", false]],     [422, ["validation", false]],
  [429, ["rate_limit", true]],     [500, ["server", true]],
  [503, ["unavailable", true]],
]);

const makeError = (type, code, retryable, status = null, detail = null) =>
  ({ type, code, retryable, status, detail });

// --- Request layer ----------------------------------------------------------
async function request(path, options = {}) {
  const { method = "GET", body = null, timeout = 1000, base = BASE } = options;
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort("timeout"), timeout);

  let response;
  try {
    response = await fetch(base + path, {
      method,
      headers: body ? { "content-type": "application/json" } : {},
      body: body ? JSON.stringify(body) : undefined,
      signal: controller.signal,
    });
  } catch {
    // The underlying layer's message depends on the environment; we surface our own code.
    return { ok: false, error: controller.signal.aborted
      ? makeError("cancel", String(controller.signal.reason), false)
      : makeError("network", "connection", true) };
  } finally {
    clearTimeout(timer);
  }

  const contentType = response.headers.get("content-type") ?? "";
  let parsed = null;
  if (contentType.startsWith("application/json")) {
    try { parsed = await response.json(); }
    catch { return { ok: false, error: makeError("parse", "unparseable", false, response.status) }; }
  } else {
    await response.text();
  }

  if (!response.ok) {
    const [code, retryable] = MAPPING.get(response.status) ?? [
      response.status >= 500 ? "server" : "request", response.status >= 500];
    return { ok: false, error: makeError("http", code, retryable, response.status, parsed) };
  }
  return { ok: true, data: parsed };
}

// --- Call sites --------------------------------------------------------
const log = (label, s) => console.log(
  label.padEnd(22),
  s.ok ? `ok    ${JSON.stringify(s.data)}`
       : `error type=${s.error.type} code=${s.error.code} status=${s.error.status} ` +
         `retry=${s.error.retryable}`);

log("station list", await request("/stations"));
log("unknown station", await request("/stations/NS-99"));
log("measurement submission", await request("/measurements", { method: "POST", body: { value: 900 } }));
log("report (not JSON)", await request("/report"));
log("timeout", await request("/slow", { timeout: 50 }));
log("same path, ample time", await request("/slow", { timeout: 1000 }));

// A closed port: the network error path.
const empty = http.createServer();
await new Promise((resolve) => empty.listen(0, "127.0.0.1", resolve));
const closedBase = `http://127.0.0.1:${empty.address().port}`;
await new Promise((resolve) => empty.close(resolve));
log("server not responding", await request("/stations", { base: closedBase }));

server.close();
station list           ok    [{"code":"NS-01","name":"North Slope","elevation":1840},{"code":"NS-02","name":"North Slope Ridge","elevation":2110}]
unknown station        error type=http code=not_found status=404 retry=false
measurement submission error type=http code=validation status=422 retry=false
report (not JSON)      error type=http code=server status=500 retry=true
timeout                error type=cancel code=timeout status=null retry=false
same path, ample time  ok    {"ready":true}
server not responding  error type=network code=connection status=null retry=true

Seven rows show all four error classes. Three points are worth noting.

First, the response body is consumed in every case. If a failed response’s body is left unread, the connection is not freed; even a non-JSON body is read as text and discarded. The fifth row’s 500 response returns HTML — the layer does not try to make sense of it, it decides from the status code.

Second, the 422 response’s body is carried into the detail field. The Validation Schemas lesson, which will feed field-level validation errors back to the form, will use this field; the request layer does not interpret the content, only carries it.

Third, cancellation and network errors fall into the same catch block but are classified separately. What makes the distinction is not the caught value but the state of the abort signal. Because the cancellation reason is carried on the signal, it can also answer “who canceled it”; here the reason is the string timeout.

Cancellation, Timeouts, and Races

The abort signal introduced in the Asynchronous JavaScript and the Runtime course is the backbone of this layer. The same mechanism serves two separate needs: giving up after a while, and giving up because the result is no longer wanted.

A timeout keeps a request from staying open forever when the server does not respond. An indefinite request is, from the user’s point of view, a loading indicator that never finishes. The right duration depends on the nature of the work — short for the station list, long for a large report — which is why the layer takes the duration per call.

The second need is subtler. If the station list has a filter field, a request is produced every time the user types. If the third request’s response arrives before the second’s, the screen is left with a stale result. Half of the solution is canceling the previous request when a new one starts; the layer accepting an abort signal from outside makes this possible. The other half — ignoring a response that arrives late — sits on the view side and is covered in the Loading and Error States lesson.

A canceled request does not surface to the view as an error. This is the only reason the layer keeps the cancel class separate: when the call site sees this outcome, it does nothing, because a new request will already fill the screen.

Resource Addresses and the Contract’s Boundary

REST’s meaning from the layer’s point of view is plain: every resource has an address, and the HTTP method says what to do. The collection is /stations, a single record is /stations/NS-01, a nested collection is /stations/NS-01/measurements. Filtering, sorting, and pagination criteria are carried in the address’s query part; they determine the resource’s view, not its identity.

This arrangement has two direct consequences on the client side. First, the address is a cache key: two requests to the same address ask for the same record and can share the result. Second, the method determines retryability. The distinction between safe and idempotent methods defined in the How the Internet Works course does its work here: repeating a read request is harmless, sending a measurement record twice can produce two records.

The contract’s boundary also appears at this point. The client has to conform to whatever level of detail the server returns resources at. If the measurement history screen shows the station name, the last measurement, and the measurement list together, three separate requests may be needed; three round trips for one screen directly multiplies latency. If the server designs combined responses per screen to avoid this, the resource model becomes tied to the screen instead, and every new screen wants a new endpoint.

Summary

  • The request layer takes the responsibilities of the base address, common headers, body resolution, and error interpretation away from the view and gathers them in one place; caching, retrying, and a loading indicator are not its job.
  • The error contract defines four classes: network, HTTP, parse, and cancellation. In a network error, whether the request reached the server is unknown; in an HTTP error, the server’s decision is known.
  • Status code mapping is kept in a single table, and codes not in the table are resolved according to the meaning of their class; 429 is retryable even though it is in the 4xx class.
  • A failed response’s body is also consumed; the structured explanation the server sends is carried, uninterpreted, in the detail field.
  • The abort signal covers both timing out and giving up; a canceled request does not surface to the view as an error.
  • A resource’s address is also a cache key; whether the HTTP method is safe and idempotent determines retryability.

Next Step

This lesson’s last section left a tension: what the screen wants is not shaped like what the resource returns. Firing three requests for the measurement history screen is costly, and so is opening a screen-specific endpoint. The root of the problem is that the server decides what gets requested. The next lesson looks at a query language that hands this decision to the client: the client writes which fields it wants and gets them in a single request. In exchange, two new problems arise — how the nested response that comes back is cached, and how the same field set is shared across screens.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close