blastoise/todo/fetch-response-validation/overview.md

4.1 KiB

Validate fetch() Responses Before Parsing JSON

Priority: Medium · Effort: Low · Risk: Low

Problem

Several client fetch call sites parse the response body as JSON without checking res.ok (or res.status). When the server returns an error envelope (e.g. { error: "Authentication required" } with 401) or a non-JSON body (proxy 502, HTML error page), the parsed object is then assigned directly to application state and immediately rendered — silently corrupting the UI.

Affected Locations

File:Line Call Current behavior on error
public/queue.js:382 M.library = await res.json(); then M.renderLibrary() The entire library silently becomes the error object; render runs on garbage.
public/auth.js:11 guest session bootstrap Error body used as the user object.
public/auth.js:101 /api/auth/me Error body used as the current user.
public/channelSync.js:11 /api/channels list Error body assigned to M.channels; channels.length === 0 check then behaves confusingly.

(Other call sites in playlists.js, upload.js, etc. already do if (!res.ok) correctly — follow those as the model.)

Implementation Plan

Step 1 — Add a shared helper in public/utils.js

Centralize the check so it can't be forgotten again:

M.apiJson = async function (res) {
  if (!res.ok) {
    let detail = "";
    try { detail = (await res.clone().json()).error ?? ""; } catch { /* non-JSON body */ }
    const err = new Error(`Request failed (${res.status})${detail ? ": " + detail : ""}`);
    err.status = res.status;
    err.detail = detail;
    throw err;
  }
  return res.json();
};

(res.clone() so the original body remains readable if the caller wants it.)

Step 2 — Update each affected call site

queue.js:~380 (library load):

const res = await fetch("/api/library", { credentials: "include" });
if (res.status === 401) { M.handleAuthRequired?.(); return; }   // or surface login UI
M.library = await M.apiJson(res);
M.renderLibrary();

Wrap the surrounding logic in try/catch and show a toast on failure (e.g. M.showToast("Couldn't load library", "error")) rather than leaving the UI empty/silent. Keep M.library as its previous value on failure rather than overwriting with garbage.

auth.js:11 (guest bootstrap) and auth.js:101 (/api/auth/me):

const res = await fetch("/api/auth/me", { credentials: "include" });
if (res.status === 401) { /* not logged in / guest expired — drive login UI */ return; }
const user = await M.apiJson(res);

channelSync.js:11 (channel list):

const res = await fetch("/api/channels", { credentials: "include" });
if (!res.ok) { M.showToast("Couldn't load channels", "error"); return; }
const channels = await M.apiJson(res);
M.channels = channels;

Step 3 — Audit remaining call sites

Grep for await res.json() and await response.json() across public/. Convert any that lack a preceding res.ok / status check to use M.apiJson. The ones already guarded can be left or migrated for consistency.

Validation

  • Simulate 401: clear the session cookie in DevTools, reload. Confirm the library view does not render an error object as tracks, and a login prompt / toast appears instead.
  • Simulate 500/502: stop the server mid-session and trigger a library reload (e.g. via the refresh path). Confirm a toast shows and the previous library remains visible.
  • Happy path: confirm normal load of library, channels, and /api/auth/me still works after the change.
  • Guest expiry: let a guest session lapse (or delete it from the DB) and reload; confirm graceful handling rather than a broken render.

Risk / Rollback

  • Behavior change only on the error path; success path is identical.
  • One subtlety: previously-silent failures will now surface as toasts. That is the desired behavior, but verify the messages are user-friendly and not noisy in normal flaky-network conditions.
  • Rollback = revert the helper and the four call sites; no data or schema impact.