refactor(api): type req<T>() and tighten the error layer (R7 S1)

req() returned Promise<any>, so every api.* return annotation was an
unchecked assertion and the 17 action endpoints leaked `any` to callers.
Make req generic with a single audited cast at the boundary; T flows from
each method's declared return type via contextual inference, so no call
site needs a type arg except where none existed:

- me(): type the /api/me probe ({ authenticated } + Partial<Me>)
- deepAll / syncSubscriptions: real backend-verified response shapes
- HttpError.detailData, localizeDetail(d), inner detailData: any -> unknown

Also teach localizeDetail the FastAPI 422 detail-ARRAY shape (carried in
from R6 S1b review): now that malformed requests return 422, surface the
offending field via a localized errors.validation key instead of falling
through to the generic toast.

No runtime change on normal flows (req is await-identical); the 422 branch
only fires on genuinely malformed input. Gate green: typecheck, eslint,
prettier, 68 vitest. Smoke-tested localdev: /api/me + feed 200, no console
errors.
This commit is contained in:
2026-07-26 21:51:24 +02:00
parent cb925e3ab9
commit 16585d868d
3 changed files with 44 additions and 15 deletions
+1
View File
@@ -1,6 +1,7 @@
{ {
"title": "Couldn't complete that", "title": "Couldn't complete that",
"generic": "The server couldn't carry out that action. Please try again.", "generic": "The server couldn't carry out that action. Please try again.",
"validation": "Invalid value for “{{field}}”. Please check it and try again.",
"server": "Server error", "server": "Server error",
"ok": "OK", "ok": "OK",
"offline": { "offline": {
+1
View File
@@ -1,6 +1,7 @@
{ {
"title": "Nem sikerült", "title": "Nem sikerült",
"generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.", "generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.",
"validation": "Érvénytelen érték: „{{field}}”. Ellenőrizd, majd próbáld újra.",
"server": "Szerverhiba", "server": "Szerverhiba",
"ok": "OK", "ok": "OK",
"offline": { "offline": {
+42 -15
View File
@@ -212,8 +212,8 @@ export interface VersionInfo {
class HttpError extends Error { class HttpError extends Error {
status: number; status: number;
detail?: string; // server-provided reason (FastAPI's `detail`), when present detail?: string; // server-provided reason (FastAPI's `detail`), when present
detailData?: any; // structured detail ({ code, reason, ... }) when the server sent an object detailData?: unknown; // structured detail ({ code, reason, ... }) when the server sent an object
constructor(status: number, detail?: string, detailData?: any) { constructor(status: number, detail?: string, detailData?: unknown) {
super(detail || `HTTP ${status}`); super(detail || `HTTP ${status}`);
this.status = status; this.status = status;
this.detail = detail; this.detail = detail;
@@ -225,20 +225,31 @@ class HttpError extends Error {
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a // 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 // 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. // with a dot-decimal separator. Falls back to a generic message for unknown shapes.
function localizeDetail(d: any): string { function localizeDetail(d: unknown): string {
const t = i18n.t.bind(i18n); const t = i18n.t.bind(i18n);
if (d?.code === "quota") { // FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
if (d.reason === "max_bytes") { // well-formed requests, so this only fires on genuinely malformed input — surface the offending
// field name (localized) rather than FastAPI's raw English `msg`, which reads wrong in a HU UI.
if (Array.isArray(d)) {
const loc = (d[0] as { loc?: unknown } | undefined)?.loc;
const field = Array.isArray(loc)
? loc.filter((s): s is string => typeof s === "string" && s !== "body").pop()
: undefined;
return field ? t("errors.validation", { field }) : t("errors.generic");
}
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( const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format(
(d.limit || 0) / 1_073_741_824, (o.limit || 0) / 1_073_741_824,
); );
return t("downloads.errors.quota.max_bytes", { size: gb }); return t("downloads.errors.quota.max_bytes", { size: gb });
} }
if (d.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: d.limit }); if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
return t("downloads.errors.quota.generic"); return t("downloads.errors.quota.generic");
} }
if (d?.code === "edit") if (o?.code === "edit")
return t(`downloads.errors.edit.${d.reason}`, t("downloads.errors.edit.generic")); return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
return t("errors.generic"); return t("errors.generic");
} }
@@ -341,7 +352,11 @@ export function clearActiveAccount(): void {
} }
} }
async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> { async function req<T = unknown>(
url: string,
opts: RequestInit = {},
cfg: ReqConfig = {},
): Promise<T> {
const method = opts.method ?? "GET"; const method = opts.method ?? "GET";
const canRetry = cfg.idempotent ?? method === "GET"; const canRetry = cfg.idempotent ?? method === "GET";
let attempt = 0; let attempt = 0;
@@ -386,7 +401,7 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
if (!r.ok) { if (!r.ok) {
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react. // Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
let detail: string | undefined; let detail: string | undefined;
let detailData: any; let detailData: unknown;
try { try {
const body = await r.json(); const body = await r.json();
if (body && typeof body.detail === "string") detail = body.detail; if (body && typeof body.detail === "string") detail = body.detail;
@@ -417,7 +432,9 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
} }
throw new HttpError(r.status, detail, detailData); throw new HttpError(r.status, detail, detailData);
} }
return r.status === 204 ? null : r.json(); // 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;
} }
} }
@@ -1098,7 +1115,8 @@ export const api = {
// (the endpoint answers `{authenticated:false}` rather than 401, so the public landing never // (the endpoint answers `{authenticated:false}` rather than 401, so the public landing never
// logs a failed request to the console). React Query treats the null as data, not an error. // logs a failed request to the console). React Query treats the null as data, not an error.
me: async (): Promise<Me | null> => { me: async (): Promise<Me | null> => {
const r = await req("/api/me"); // Always 200: `{ authenticated: false }` when logged out, else the full Me + `authenticated`.
const r = await req<{ authenticated: boolean } & Partial<Me>>("/api/me");
return r && r.authenticated ? (r as Me) : null; return r && r.authenticated ? (r as Me) : null;
}, },
accounts: (): Promise<Account[]> => req("/api/me/accounts"), accounts: (): Promise<Account[]> => req("/api/me/accounts"),
@@ -1176,7 +1194,8 @@ export const api = {
) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), ) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
resetChannelBackfill: (id: string) => resetChannelBackfill: (id: string) =>
req(`/api/channels/${id}/reset-backfill`, { method: "POST" }), req(`/api/channels/${id}/reset-backfill`, { method: "POST" }),
deepAll: (on = true) => req(`/api/sync/deep-all?on=${on}`, { method: "POST" }), deepAll: (on = true): Promise<{ updated: number; deep_requested: boolean }> =>
req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
attachChannelTag: (id: string, tagId: number) => attachChannelTag: (id: string, tagId: number) =>
req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }), req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }),
detachChannelTag: (id: string, tagId: number) => detachChannelTag: (id: string, tagId: number) =>
@@ -1184,7 +1203,15 @@ export const api = {
unsubscribeChannel: (id: string) => req(`/api/channels/${id}/subscription`, { method: "DELETE" }), unsubscribeChannel: (id: string) => req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
discoveredChannels: (): Promise<DiscoveredChannel[]> => req("/api/channels/discovery"), discoveredChannels: (): Promise<DiscoveredChannel[]> => req("/api/channels/discovery"),
subscribeChannel: (id: string) => req(`/api/channels/${id}/subscribe`, { method: "POST" }), subscribeChannel: (id: string) => req(`/api/channels/${id}/subscribe`, { method: "POST" }),
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }), syncSubscriptions: (): Promise<{
subscriptions: number;
channels_new: number;
channels_detailed: number;
removed_stale: number;
prune_skipped: boolean;
quota_used_estimate: number;
quota_remaining_today: number;
}> => req("/api/sync/subscriptions", { method: "POST" }),
// --- channel page (About detail + ephemeral exploration of un-subscribed channels) --- // --- channel page (About detail + ephemeral exploration of un-subscribed channels) ---
channelDetail: (id: string): Promise<ChannelDetail> => req(`/api/channels/${id}`), channelDetail: (id: string): Promise<ChannelDetail> => req(`/api/channels/${id}`),
// Open an un-subscribed channel for browsing: ingest a page of its uploads (newest-first, // Open an un-subscribed channel for browsing: ingest a page of its uploads (newest-first,