refactor(api): extract the shared HTTP machinery to api/core.ts (R7 S3b·1)
First step of splitting the api.ts god module behind its façade. Move req, HttpError, localizeDetail, the connectivity toast, ReqConfig, the per-tab active-account plumbing (getActiveAccount/setActiveAccount/clearActiveAccount/ beacon), setupHeaders, and setUnauthorizedHandler into lib/api/core.ts. api.ts imports the machinery it uses and re-exports the public surface, so the 53 call sites importing from "./api" are unchanged. api.ts 1793 -> 1583 lines; no runtime change (pure move). Gate green: typecheck, eslint, prettier, 74 vitest.
This commit is contained in:
+19
-230
@@ -1,7 +1,22 @@
|
||||
import { notify, remove } from "./notifications";
|
||||
import i18n from "../i18n";
|
||||
import { reportError } from "./errorDialog";
|
||||
import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "./storage";
|
||||
import {
|
||||
req,
|
||||
beacon,
|
||||
setupHeaders,
|
||||
getActiveAccount,
|
||||
HttpError,
|
||||
setActiveAccount,
|
||||
clearActiveAccount,
|
||||
setUnauthorizedHandler,
|
||||
} from "./api/core";
|
||||
|
||||
// Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged.
|
||||
export {
|
||||
HttpError,
|
||||
getActiveAccount,
|
||||
setActiveAccount,
|
||||
clearActiveAccount,
|
||||
setUnauthorizedHandler,
|
||||
};
|
||||
|
||||
export interface Me {
|
||||
id: number;
|
||||
@@ -219,230 +234,6 @@ export interface VersionInfo {
|
||||
db_revision: string | null;
|
||||
}
|
||||
|
||||
class HttpError extends Error {
|
||||
status: number;
|
||||
detail?: string; // server-provided reason (FastAPI's `detail`), when present
|
||||
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.
|
||||
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 } | 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"));
|
||||
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 {
|
||||
if (connectivityNotifId !== null) return;
|
||||
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));
|
||||
|
||||
// `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.
|
||||
interface ReqConfig {
|
||||
idempotent?: 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).
|
||||
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";
|
||||
|
||||
export const getActiveAccount = activeAccountId;
|
||||
|
||||
// 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.
|
||||
function beacon(url: string, body: Record<string, unknown>): void {
|
||||
const active = getActiveAccount();
|
||||
try {
|
||||
void fetch(url, {
|
||||
method: "POST",
|
||||
keepalive: true,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function setActiveAccount(id: number): void {
|
||||
try {
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
export function clearActiveAccount(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const active = getActiveAccount();
|
||||
r = await fetch(url, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Per-tab identity override (only setup calls pass their own headers, and those are
|
||||
// pre-auth, so this is safe to put in the defaults that `...opts` may replace).
|
||||
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
|
||||
},
|
||||
...opts,
|
||||
});
|
||||
} catch (e) {
|
||||
// Network-level failure (incl. a keepalive connection reset surfacing as a TypeError).
|
||||
if (canRetry && attempt === 0) {
|
||||
attempt++;
|
||||
await delay(250);
|
||||
continue;
|
||||
}
|
||||
markConnectivityLost();
|
||||
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();
|
||||
if (body && typeof body.detail === "string") detail = body.detail;
|
||||
else if (body && body.detail && typeof body.detail === "object") {
|
||||
detailData = body.detail;
|
||||
detail = localizeDetail(body.detail); // structured error → localized message
|
||||
}
|
||||
} catch {
|
||||
/* no JSON body */
|
||||
}
|
||||
// 502/503/504 mean the gateway couldn't reach the app (restarting / connection reset),
|
||||
// not a request the app refused — treat them like a network blip with the soft,
|
||||
// self-clearing toast rather than a blocking modal the user must acknowledge.
|
||||
// A genuine 500 (real fault, carries a JSON detail) still gets the modal, as do
|
||||
// 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow.
|
||||
if (RETRIABLE_GATEWAY.has(r.status)) {
|
||||
markConnectivityLost();
|
||||
} else if (r.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.
|
||||
onUnauthorized?.();
|
||||
} else if (cfg.quiet) {
|
||||
/* caller handles the error inline — no global modal */
|
||||
} else if (r.status >= 500) {
|
||||
reportError(detail || `${i18n.t("errors.server")} (${r.status})`);
|
||||
} else if (r.status === 400 || r.status === 409 || r.status === 422) {
|
||||
reportError(detail);
|
||||
}
|
||||
throw new HttpError(r.status, detail, detailData);
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// The filter half of the query (everything except paging/sort), shared by the feed and
|
||||
// the facet-count endpoint so both see exactly the same filter context.
|
||||
function filterParams(f: FeedFilters): URLSearchParams {
|
||||
@@ -1789,5 +1580,3 @@ export const api = {
|
||||
adminResetDownloadQuota: (userId: number): Promise<AdminDownloadQuota> =>
|
||||
req(`/api/admin/downloads/quota/${userId}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
export { HttpError };
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
// 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 { notify, remove } from "../notifications";
|
||||
import i18n from "../../i18n";
|
||||
import { reportError } from "../errorDialog";
|
||||
import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "../storage";
|
||||
|
||||
export class HttpError extends Error {
|
||||
status: number;
|
||||
detail?: string; // server-provided reason (FastAPI's `detail`), when present
|
||||
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.
|
||||
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 } | 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"));
|
||||
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 {
|
||||
if (connectivityNotifId !== null) return;
|
||||
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));
|
||||
|
||||
// `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;
|
||||
// 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";
|
||||
|
||||
export const getActiveAccount = activeAccountId;
|
||||
|
||||
// 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 {
|
||||
const active = getActiveAccount();
|
||||
try {
|
||||
void fetch(url, {
|
||||
method: "POST",
|
||||
keepalive: true,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function setActiveAccount(id: number): void {
|
||||
try {
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
export function clearActiveAccount(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const active = getActiveAccount();
|
||||
r = await fetch(url, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Per-tab identity override (only setup calls pass their own headers, and those are
|
||||
// pre-auth, so this is safe to put in the defaults that `...opts` may replace).
|
||||
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
|
||||
},
|
||||
...opts,
|
||||
});
|
||||
} catch (e) {
|
||||
// Network-level failure (incl. a keepalive connection reset surfacing as a TypeError).
|
||||
if (canRetry && attempt === 0) {
|
||||
attempt++;
|
||||
await delay(250);
|
||||
continue;
|
||||
}
|
||||
markConnectivityLost();
|
||||
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();
|
||||
if (body && typeof body.detail === "string") detail = body.detail;
|
||||
else if (body && body.detail && typeof body.detail === "object") {
|
||||
detailData = body.detail;
|
||||
detail = localizeDetail(body.detail); // structured error → localized message
|
||||
}
|
||||
} catch {
|
||||
/* no JSON body */
|
||||
}
|
||||
// 502/503/504 mean the gateway couldn't reach the app (restarting / connection reset),
|
||||
// not a request the app refused — treat them like a network blip with the soft,
|
||||
// self-clearing toast rather than a blocking modal the user must acknowledge.
|
||||
// A genuine 500 (real fault, carries a JSON detail) still gets the modal, as do
|
||||
// 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow.
|
||||
if (RETRIABLE_GATEWAY.has(r.status)) {
|
||||
markConnectivityLost();
|
||||
} else if (r.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.
|
||||
onUnauthorized?.();
|
||||
} else if (cfg.quiet) {
|
||||
/* caller handles the error inline — no global modal */
|
||||
} else if (r.status >= 500) {
|
||||
reportError(detail || `${i18n.t("errors.server")} (${r.status})`);
|
||||
} else if (r.status === 400 || r.status === 409 || r.status === 422) {
|
||||
reportError(detail);
|
||||
}
|
||||
throw new HttpError(r.status, detail, detailData);
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user