Files
siftlode/frontend/src/lib/api/core.ts
T
peter 7c0647f87a fix(uploads): finish the sweeps round 1 left half-done
Deep-review round 2, on round 1's own fix commit. It found MORE than round 1
did (28 raw, against 13), and most of it was mine -- a new piece of UI attracts
findings a backend-only diff never does. The pattern in almost all of them is
the same: a principle applied to the site that prompted it and not to its
siblings.

* `display_text` reached three of the FIVE writers of a user-supplied name.
  `POST /api/downloads` and `POST /api/downloads/edit` still handed Postgres an
  unbounded string, so a 300-character name pasted into the Add-download dialog
  or the editor was a bare 500 (main.py re-raises every DataError but the NUL
  case). Four of the seven reviewers found it independently. The bound now lives
  in `models.py`, the three columns are DECLARED with it, and the guard for the
  next writer is parsed rather than remembered: every `display_name` /
  `display_uploader` assignment under `app/routes/` must go through the helper.
  That guard immediately found two more -- the Google sign-in callback writes
  the profile name into the same 255-wide column -- so it is six, not five.

* The connection release reached one of the THREE routes that read their own
  body. The screenshot route (10 MiB, from a phone) and the client log sink both
  still held a pooled connection idle-in-transaction for the length of the
  upload, which is the invariant db.py sizes the pool on. All three now call
  `db.release_read_connection`, whose precondition -- nothing pending on the
  shared session -- is CHECKED rather than described, because a whole-session
  rollback discarding a caller's staged write is a mistake this repo has already
  made once. `test_body_limit.py` holds the rule for the next such route.

* The AST drift registry keyed its sites by (module, limit expression), so a
  second route copied from the first collapsed onto the same entry and went
  uncovered -- the exact hole the registry exists to close. Keyed per call site
  now, and it carries the release requirement too.

* The dialog's own refusal was announced once and never again: two over-cap
  picks leave an identical node with identical text, and an untouched live
  region says nothing. It is keyed per pick now, the drop zone reads as invalid
  and points at the sentence, and the reason is reachable from the control
  rather than only from a one-shot announcement.

* Two tests rebuilt the blind spot they were written to remove: the dialog's cap
  test sat nowhere near the boundary (a `>` loosened to `>=` survived it, which
  is the mutation round 1 fixed on the server side), and the header-precedence
  test only covered an exact-case duplicate while a differently-cased one would
  have been COMBINED onto the wire rather than replaced. Both fixed, and the
  merge now dedupes case-insensitively because header names are.

Also: the upload dialog's unstubbed usage query was firing a real request from
jsdom in a neighbouring suite and leaking a delayed "connection lost" toast
across test boundaries; `GET /api/downloads/usage` had no test at all though the
whole client-side check hangs off it; and the comment claiming the edge 413 is
readable now says what was measured -- readable to 200 MiB over loopback, a bare
`onerror` through a proxy -- so the pre-send check is named as the mechanism and
that branch as the backstop it is.

Every test added or changed here was mutation-checked. One mutation SURVIVED and
is recorded rather than papered over: the `tooBig` term inside `submit()` cannot
be reached, because `ui/Button` swallows the activation itself, so it is marked
as belt and braces instead of being described as the guard.
2026-09-22 23:31:55 +02:00

484 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The shared HTTP machinery behind every api.* domain module: the fetch wrapper (`req`), the typed
// error, structured-error localization, the connectivity toast, the per-tab active-account plumbing,
// and the keepalive beacon. Domain modules import from here; nothing here imports a domain module,
// so the runtime graph is a clean barrel → domain → core with no cycles.
import { getActiveToasts, notify, remove } from "../notifications";
import i18n from "../../i18n";
import { formatBytes } from "../format";
import { reportError } from "../errorDialog";
import { activeAccountId, pinActiveAccount } from "../activeAccount";
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
import { currentDebugSession, newRequestId } from "../log";
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
* every caller is one every caller can forget, which is how the registration language shipped as a
* field nobody ever filled (E9.5 S6 → S7). */
export const uiLang = (): string => i18n.language.split("-")[0] || "en";
// Errors `req` has already put in front of the user for good — the blocking error dialog, or the
// sign-out a 401 triggers. A caller that toasts a failed action as well reported the same fault twice
// (a modal AND a toast); `notifyActionError` asks this instead of each caller filtering statuses by
// hand.
//
// NOT the connection notice. It says the server is unreachable, not that this action failed, and it
// removes itself on the next answer — which every caller's own resync refetch fetches at once. Marked
// as reported, a delete that hit a keepalive reset (a 502) flashed the notice for a moment and was
// then reported nowhere at all, the list still there (deep-review round 4).
//
// A caller that reports a failure itself marks it too (`notifyActionError` does), which is what lets
// the ONE app-wide mutation reporter (lib/queryClient) stay quiet for a mutation whose own `onError`
// already said something, and speak for every one that did not.
const reported = new WeakSet<object>();
export function alreadyReported(err: unknown): boolean {
return typeof err === "object" && err !== null && reported.has(err);
}
/** Record that this failure is on screen now, so nothing reports it a second time. */
export function markReported(err: unknown): void {
if (typeof err === "object" && err !== null) reported.add(err);
}
/** What an upload the USER cancelled rejects with. A cancel is not a failure to report, and the
* only other rejection carrying status 0 is a genuine network drop, which IS worth reporting — so
* the difference has to be a named thing both sides agree on, not a string literal in each. */
const ABORTED = "aborted";
/** True for the rejection of a request the user cancelled (see `ABORTED`). */
export function isAborted(err: unknown): boolean {
return err instanceof HttpError && err.status === 0 && err.detail === ABORTED;
}
export class HttpError extends Error {
status: number;
// The server's reason IN THE READER'S LANGUAGE, when it gave one — i.e. a structured detail run
// through `localizeDetail`, never the raw text of a string detail. Every refusal the app answers is
// structured (backend `app/refusal.py`, enforced by its test_http_details.py); a string can only be
// the framework's own ("Method Not Allowed"), so it is left out rather than shown. That is what
// makes reading `.detail` safe anywhere — including destructured, which no source scan could see.
detail?: string;
detailData?: unknown; // structured detail ({ code, reason, ... }) when the server sent an object
constructor(status: number, detail?: string, detailData?: unknown) {
super(detail || `HTTP ${status}`);
this.status = status;
this.detail = detail;
this.detailData = detailData;
}
}
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
// The refusal codes of the admin's account and invite actions (routes/admin.py). Their reasons share
// one namespace, `errors.admin.<reason>`, which i18n/backendIds.contract checks against the backend.
const ADMIN_REFUSALS = new Set(["activate", "resend", "invite_send", "setpw"]);
function localizeDetail(d: unknown): string {
const t = i18n.t.bind(i18n);
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
// well-formed requests, so this only fires on genuinely malformed input. The per-field `msg` and
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
// "check your input" message rather than leaking them into a translated sentence.
if (Array.isArray(d)) return t("errors.validation");
const o = d as
| { code?: string; reason?: string; limit?: number; min?: number }
| null
| undefined;
if (o?.code === "quota") {
if (o.reason === "max_bytes") {
const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format(
(o.limit || 0) / 1_073_741_824,
);
return t("downloads.errors.quota.max_bytes", { size: gb });
}
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
return t("downloads.errors.quota.generic");
}
if (o?.code === "edit")
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
if (o?.code === "cookies")
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
// Password sign-in refusals (E9.5 S4). The card renders the account-shaped ones as a full panel
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
// gets. A reason this build doesn't know falls through to the generic message rather than showing
// a raw key, which is what an i18n miss would otherwise put on screen.
if (o?.code === "login")
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
// The shared demo account refusing a personal-state write (`require_human`). Not every affordance
// behind that gate is UI-gated on `is_demo` — the comfort shelf is not — so without this the demo
// user got their caller's generic "couldn't do that" and no reason at all (round 6).
if (o?.code === "demo") return t("errors.demo");
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
if (o?.code === "reset")
return o.reason === "weak_password"
? t("welcome.auth.weakPassword", { min: o.limit ?? o.min })
: t("welcome.auth.deadLink.short");
// Local-upload ingest (E7 S5): the per-file cap carries the limit in bytes; empty is a bare code.
if (o?.code === "upload_too_large")
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit) });
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
// number. Not a theoretical case: on a DEFAULT install it is what an over-cap upload gets,
// because the ceiling and the route's own cap are the same figure and the edge answers first —
// the route's numbered `upload_too_large` above is the answer only once an admin has LOWERED the
// knob (see bodylimit.ROUTE_MAX_BODY_BYTES). Without this branch that refusal fell through to
// the generic "couldn't carry out that action", the one message no retry can fix.
//
// A BACKSTOP, not the plan. The middleware answers without draining, so whether the browser gets
// to read this at all depends on the client having finished sending: measured readable up to
// 200 MiB over loopback, and measured as a bare `onerror` through a dev proxy. That is why the
// upload dialog checks the file against `upload_max_bytes` BEFORE sending — this branch is what
// happens when that check could not run.
if (o?.code === "too_large") return t("errors.tooLarge");
// SEC-12: a download URL the server refuses to fetch. The reasons are deliberately distinct —
// "we could not look that host up" is a different thing to tell someone than "we will not fetch
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
if (o?.code === "url_blocked")
return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic"));
// E10 S2: the one-step "move to another list". Both reasons mean the same thing to a reader —
// the list moved under you — but they are different enough to be worth naming separately, and an
// unknown reason falls back to the plain failure rather than printing a key.
if (o?.code === "playlist_move")
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed"));
// A Plex collection name already taken in that library (the one collection-edit refusal).
if (o?.code === "collection")
return o.reason === "name_taken"
? t("plex.collection.nameTaken")
: t("plex.collection.editFailed");
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it —
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
if (o?.code === "youtube")
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and
// the rest of the detail its values (`{{min}}`, `{{name}}`, …). A reason this build does not know
// reads as the generic failure rather than a key.
if (o?.code === "refused")
return t(`errors.refused.${o.reason}`, { ...o, defaultValue: t("errors.generic") });
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
// An admin action on an account or an invite that the server refused: activating an address that
// is no longer approved, a confirmation link or invitation with nothing to go to or no mail to go
// by, a set-password link for the shared demo account. The toast's title already names the button;
// this is the WHY, and it used to be the generic "try again", which no retry could fix.
if (ADMIN_REFUSALS.has(o?.code ?? "")) return t(`errors.admin.${o?.reason}`, t("errors.generic"));
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
// row 404s the same way). `reason` names WHAT is gone; one this build doesn't know says it
// generically rather than printing a key.
if (o?.code === "not_found")
return t(`errors.notFound.${o.reason}`, t("errors.notFound.generic"));
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
// limit, and the copy says so.
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
return t("errors.generic");
}
// A single, self-resolving "connection lost" status: while the server is unreachable we
// show one sticky, transient (non-persisted) notice; the moment any request reaches the
// server again we remove it. This makes good on its "clears once it's back" copy and keeps
// bursts of concurrent failures from stacking up — there's only ever one live handle.
let connectivityNotifId: number | null = null;
function markConnectivityLost(): void {
// One live notice — but a notice the user closed is not live: it used to swallow every later
// "connection lost" for the rest of the visit.
const id = connectivityNotifId;
if (id !== null && getActiveToasts().some((n) => n.id === id)) return;
if (id !== null) remove(id);
connectivityNotifId = notify({
level: "error",
title: i18n.t("errors.offline.title"),
message: i18n.t("errors.offline.body"),
transient: true,
});
}
function markConnectivityRestored(): void {
if (connectivityNotifId === null) return;
remove(connectivityNotifId);
connectivityNotifId = null;
}
// Gateway statuses that mean "the upstream connection failed", not "the app rejected the
// request" — typically a reverse-proxy ↔ uvicorn keepalive connection reset before the
// request was processed. Safe to retry an idempotent call once on a fresh connection.
const RETRIABLE_GATEWAY = new Set([502, 503, 504]);
const delay = (ms: number) => new Promise((res) => window.setTimeout(res, ms));
// What a failed answer puts on screen — ONE policy for `req` and `uploadReq` (the upload path kept
// its own copy, and it had drifted: a 502 there opened the blocking "Server error" dialog and
// cleared a connection notice that was still true).
//
// 502/503/504 mean the gateway couldn't reach the app (restarting / connection reset), not a
// request the app refused — the soft, self-clearing connection notice rather than a blocking modal,
// and still the caller's to report as a failed action (see `reported`). A genuine 500 (real fault,
// carries a JSON detail) gets the modal, as do 400/409/422 (validation/conflict). 401 is the app
// shell's; 403/404/429 are caller-handled control flow.
function reportFailure(err: HttpError, quiet: boolean): void {
if (RETRIABLE_GATEWAY.has(err.status)) {
markConnectivityLost();
} else if (err.status === 401) {
// Session ended server-side (e.g. the account was suspended or deleted mid-visit). Let the app
// shell decide what to do (drop to the login page if we were signed in). Not a modal — but
// handled all the same, so not the caller's to toast either.
onUnauthorized?.();
reported.add(err);
} else if (quiet) {
/* caller handles the error inline — no global modal */
} else if (err.status >= 500) {
reportError(err.detail || `${i18n.t("errors.server")} (${err.status})`);
reported.add(err);
} else if (err.status === 400 || err.status === 409 || err.status === 422) {
reportError(err.detail);
reported.add(err);
}
}
// `idempotent`: may this request be replayed after a transient gateway/network failure?
// GETs always can; non-GET callers opt in (e.g. the player's progress/state writes, which
// are safe to repeat). Used to recover from the keepalive race without surfacing a 502.
export interface ReqConfig {
idempotent?: boolean;
// Return the `Response` itself instead of parsed JSON — for a file (see the return below).
raw?: boolean;
// Suppress the global error modal for 4xx/5xx so the caller can show the error inline
// (used by the auth forms — a wrong password shouldn't pop a blocking dialog).
quiet?: boolean;
}
// Set by the app shell. Invoked whenever any request returns 401 so a session that ended
// server-side (account suspended/deleted while the tab was open) can drop the user to the login
// page. The handler itself guards against firing when we were never signed in, so it's safe to
// call for every 401. (The bootstrap /api/me probe no longer 401s — it returns 200 with a null
// user when logged out — but other protected endpoints still do.)
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: (() => void) | null): void {
onUnauthorized = fn;
}
// Headers for install-wizard calls: the one-time setup token plus the JSON content type (req
// replaces — not merges — its default headers when opts.headers is given, so include both).
export const setupHeaders = (token: string) => ({
"Content-Type": "application/json",
"X-Setup-Token": token,
});
// --- Per-tab active account --------------------------------------------------------------
// The signed session cookie is the browser's account "wallet"; which account a given TAB acts
// as is chosen here and sent per-request, so two tabs can run two accounts at once. Stored in
// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default.
// The storage key + reader live in storage.ts (so its per-account helpers can use them too).
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";
// The debug session this tab is recording (E16): the server tags its own lines of the request with
// it, and logs them at the session's level. Ignored by the server unless it is the caller's own.
const DEBUG_SESSION_HEADER = "X-Debug-Session";
export const getActiveAccount = activeAccountId;
/** Who this request is from — the tab's account and the debug session it records into — as the
* headers every request path sends (`req`, `beacon`, `uploadReq`): one definition, so a path
* cannot quietly go out without them. */
export function identityHeaders(): Record<string, string> {
const active = getActiveAccount();
const session = currentDebugSession();
return {
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
...(session ? { [DEBUG_SESSION_HEADER]: session } : {}),
};
}
// Fire-and-forget POST that survives page unload (keepalive) — shared by the resume-position
// beacons (YT + Plex players). A normal fetch is cancelled on unload and React effect cleanup
// doesn't run on a full reload, so without keepalive the last position (esp. right after a seek)
// would be lost.
export function beacon(url: string, body: Record<string, unknown>): void {
try {
void send(url, {
method: "POST",
keepalive: true,
credentials: "include",
headers: { "Content-Type": "application/json", ...identityHeaders() },
body: JSON.stringify(body),
}).catch(() => {});
} catch {
/* ignore */
}
}
export const setActiveAccount = pinActiveAccount;
export async function req<T = unknown>(
url: string,
opts: RequestInit = {},
cfg: ReqConfig = {},
): Promise<T> {
const method = opts.method ?? "GET";
const canRetry = cfg.idempotent ?? method === "GET";
let attempt = 0;
for (;;) {
let r: Response;
try {
r = await send(
url,
{
credentials: "include",
headers: {
"Content-Type": "application/json",
// Per-tab identity override (only setup calls and raw uploads pass their own headers:
// setup is pre-auth, and an upload spreads `identityHeaders()` into its own).
...identityHeaders(),
},
...opts,
},
attempt ? { attempt: attempt + 1 } : {},
);
} catch (e) {
// Network-level failure (incl. a keepalive connection reset surfacing as a TypeError).
if (canRetry && attempt === 0) {
attempt++;
await delay(250);
continue;
}
markConnectivityLost(); // the connection notice; the failed action is still the caller's
throw e;
}
// A gateway error usually means the upstream connection was reset before our request
// was processed — retry idempotent calls once on a fresh connection before surfacing it.
if (!r.ok && canRetry && attempt === 0 && RETRIABLE_GATEWAY.has(r.status)) {
attempt++;
await delay(250);
continue;
}
// The server answered (anything that isn't a gateway error means it's reachable) —
// clear a standing "connection lost" status if one is showing.
if (!RETRIABLE_GATEWAY.has(r.status)) markConnectivityRestored();
if (!r.ok) {
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
let detail: string | undefined;
let detailData: unknown;
try {
const body = await r.json();
// A string detail is not the app's (see `HttpError.detail`) and is not shown.
if (body && body.detail && typeof body.detail === "object") {
detailData = body.detail;
detail = localizeDetail(body.detail); // structured error → localized message
}
} catch {
/* no JSON body */
}
const err = new HttpError(r.status, detail, detailData);
reportFailure(err, !!cfg.quiet);
throw err;
}
// A file (`cfg.raw`): the answer itself, for the caller to read as a blob. For a SMALL generated
// file whose endpoint can refuse (a report, an export): sent as a plain `<a href>` it would
// carry no refusal the app could show — the browser would simply save the error page. A large
// stored file stays a plain navigation (the download centre, the Plex original), so the browser
// streams it to disk instead of buffering gigabytes into the tab.
if (cfg.raw) return r as T;
// The single audited cast in this layer: the server contract is asserted here, once, so
// every typed api.* method (T comes from its declared return type) is checked from here up.
return (r.status === 204 ? null : await r.json()) as T;
}
}
// Upload with progress. `req` rides `fetch`, which can't report upload progress, so a file upload
// uses XHR instead: the caller gets a 0–100% callback as the body streams and an optional
// AbortSignal to cancel a large upload. Everything else mirrors `req` — the per-tab account header,
// credentials, the structured-detail localization, the global error modal for 4xx/5xx, and an
// `HttpError` throw — so callers handle failures exactly as they do a `req` call.
//
// `body` is a `Blob`/`File` — the bytes themselves, and nothing else. `FormData` was the other
// accepted shape until P1 and is deliberately gone: a multipart part is a DECLARED body, which
// FastAPI spools to disk before the route's auth dependency runs, and the backend dropped
// `python-multipart` altogether, so there is no longer anything on the other end that could parse
// one. `cfg.headers` is how a caller sends the metadata that used to ride as sibling form fields.
//
// Content-Type is never set here: the browser derives it from the Blob's own type, and a wrong one
// is worse than none.
export function uploadReq<T = unknown>(
url: string,
body: Blob,
cfg: {
onProgress?: (pct: number) => void;
signal?: AbortSignal;
headers?: Record<string, string>;
} = {},
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.withCredentials = true;
const requestId = newRequestId();
const started = performance.now();
xhr.setRequestHeader(REQUEST_ID_HEADER, requestId);
// `identityHeaders()` LAST, and LOWER-CASED on the way in: who the request is from is this
// layer's to state, not the caller's. Merged the other way round a caller could silently
// re-attribute the upload; merged case-sensitively, a caller spelling the same header in
// another case would not be replaced but APPENDED — `setRequestHeader` combines values for a
// name that already exists, so the wire would carry both, comma-joined. Header names are
// case-insensitive, so the merge has to be too.
const merged = new Map<string, [string, string]>(); // lower-cased name -> [as spelled, value]
for (const [name, value] of Object.entries(cfg.headers ?? {}))
merged.set(name.toLowerCase(), [name, value]);
for (const [name, value] of Object.entries(identityHeaders()))
merged.set(name.toLowerCase(), [name, value]);
for (const [name, value] of merged.values()) xhr.setRequestHeader(name, value);
if (cfg.onProgress) {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) cfg.onProgress?.(Math.round((e.loaded / e.total) * 100));
};
}
if (cfg.signal) {
if (cfg.signal.aborted) return reject(new HttpError(0, ABORTED));
cfg.signal.addEventListener("abort", () => xhr.abort());
}
xhr.onload = () => {
logRequest(requestId, "POST", url, started, {
status: xhr.status,
// Test doubles may not implement it; a real XHR always does.
route: xhr.getResponseHeader?.(ROUTE_HEADER),
quiet: xhr.getResponseHeader?.(QUIET_HEADER) === "1",
});
if (!RETRIABLE_GATEWAY.has(xhr.status)) markConnectivityRestored(); // as in `req`
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve((xhr.responseText ? JSON.parse(xhr.responseText) : null) as T);
} catch {
resolve(null as T);
}
return;
}
let detail: string | undefined;
let detailData: unknown;
try {
const body = JSON.parse(xhr.responseText);
// A string detail is not the app's (see `HttpError.detail`) and is not shown.
if (body && body.detail && typeof body.detail === "object") {
detailData = body.detail;
detail = localizeDetail(body.detail);
}
} catch {
/* no JSON body */
}
const err = new HttpError(xhr.status, detail, detailData);
reportFailure(err, false);
reject(err);
};
// A network-level failure (server unreachable) — same soft "connection lost" status as `req`.
xhr.onerror = () => {
logRequest(requestId, "POST", url, started, { failed: "network" });
markConnectivityLost();
reject(new HttpError(0, i18n.t("errors.offline.body")));
};
xhr.onabort = () => {
logRequest(requestId, "POST", url, started, { failed: "aborted" });
reject(new HttpError(0, ABORTED));
};
xhr.send(body);
});
}