fix(api): a gone row and a refused playlist action are said in the reader's language, and a failure is reported once
Deep-review round 3 of bug hunt 2026-09.
- get_or_404 / owned_or_404 answer a structured {code: "not_found", reason: <what>}
(errors.notFound.* in HU+EN, generic fallback). The structured-404 fix of round 2 had gone into the
ad-hoc `_own_playlist` (CLAUDE.md's "Not" pattern) while the shared helpers stayed string-only, so
views, tags, pins, Plex playlists, invites and users still 404'd in English. `_own_playlist` now
goes through owned_or_404; admin's hand-rolled user lookups use get_or_404.
- A move into a list that is gone names the TARGET (reason "move_target") — "this playlist doesn't
exist any more" was read as the list on screen, which still exists.
- Every refusal in the playlists router is a {code: "playlist", reason} (playlists.errors.* HU+EN)
instead of a bare English string shown verbatim in the Hungarian UI.
- `req` / `uploadReq` mark the errors they already put on screen (the error dialog, the connection
notice, the 401 sign-out), and notifyActionError skips those. The watched toggles, the saved views
and a playlist delete toasted a fault the dialog was already showing; Playlists' two hand-written
status filters now go through the same check.
This commit is contained in:
@@ -40,7 +40,7 @@ import {
|
||||
X,
|
||||
Youtube,
|
||||
} from "lucide-react";
|
||||
import { api, HttpError, type Playlist, type PlaylistDetail, type Video } from "../lib/api";
|
||||
import { api, type Playlist, type PlaylistDetail, type Video } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
@@ -576,12 +576,10 @@ export default function Playlists() {
|
||||
refreshAll(listId);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// `req` raises the global dialog for 4xx-validation and 5xx, but leaves 403/404 to the caller
|
||||
// (the list was deleted or de-owned meanwhile) — so say it here, as moveItemTo does, or a
|
||||
// confirmed removal fails in silence (inside the full-screen player there is not even a row
|
||||
// snapping back to show it).
|
||||
if (e instanceof HttpError && (e.status === 404 || e.status === 403))
|
||||
notifyActionError(e, t("playlists.removeFailed"));
|
||||
// Said here, as moveItemTo does, or a confirmed removal fails in silence (inside the
|
||||
// full-screen player there is not even a row snapping back to show it) — unless `req` already
|
||||
// said it (the error dialog, the connection notice), which `notifyActionError` checks.
|
||||
notifyActionError(e, t("playlists.removeFailed"));
|
||||
refreshAll(listId); // …and resync, which restores any row the optimistic removal took out
|
||||
return false;
|
||||
}
|
||||
@@ -613,11 +611,9 @@ export default function Playlists() {
|
||||
// every move failure a user can actually reach is a 404 (the 400 `same_playlist` case is already
|
||||
// impossible — the submenu never offers the current list). So the row used to snap back with no
|
||||
// explanation and the shipped moveErrors strings were unreachable (E10 review round 1).
|
||||
// ONLY the statuses `req` deliberately leaves to the caller. It already opens the blocking
|
||||
// error dialog for 400/409/422 and 5xx and raises the connectivity notice for a bad gateway,
|
||||
// so toasting unconditionally reported a transport fault twice (E10 review round 2).
|
||||
if (e instanceof HttpError && (e.status === 404 || e.status === 403))
|
||||
notifyActionError(e, t("playlists.moveFailed"));
|
||||
// A fault `req` already reported (the error dialog, the connection notice) is not toasted
|
||||
// again — `notifyActionError` checks (E10 review round 2 found it reported twice).
|
||||
notifyActionError(e, t("playlists.moveFailed"));
|
||||
}
|
||||
// BOTH sides changed, and so did this video's membership. `qk.playlist(selectedId)` is an exact
|
||||
// key, so it cannot cover the target — invalidate the whole `["playlist"]` prefix, exactly as
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A failed playlist delete is reported once (deep-review round 3 of bug hunt 2026-09). `req` already
|
||||
// opens the blocking error dialog for a 422 (a YouTube-side delete failure) and a 5xx; the delete
|
||||
// then toasted the same failure as well — in English, before the router's errors were structured.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderHook, act, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const h = vi.hoisted(() => ({ notify: vi.fn(), reportError: vi.fn() }));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
notify: (...a: unknown[]) => h.notify(...a),
|
||||
}));
|
||||
vi.mock("../lib/errorDialog", () => ({ reportError: (...a: unknown[]) => h.reportError(...a) }));
|
||||
vi.mock("./ConfirmProvider", () => ({ useChoice: () => async () => "both" }));
|
||||
vi.mock("./PlaylistsProvider", () => ({
|
||||
usePlaylists: () => ({ selectedId: null }),
|
||||
usePlaylistsActions: () => ({ setSelectedId: () => {} }),
|
||||
}));
|
||||
vi.mock("../lib/useMe", () => ({ useMe: () => ({ can_write: true }) }));
|
||||
|
||||
import { usePlaylistActions } from "./usePlaylistActions";
|
||||
import { HttpError, api } from "../lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
h.notify.mockReset();
|
||||
h.reportError.mockReset();
|
||||
});
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
return renderHook(() => usePlaylistActions(), { wrapper });
|
||||
}
|
||||
|
||||
const linked = { id: 4, name: "Mix", kind: "user", yt_playlist_id: "PL1" } as const;
|
||||
|
||||
describe("usePlaylistActions — deleting a list", () => {
|
||||
it("does not toast a failure the request layer already put on screen", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: { code: "playlist", reason: "youtube_failed" } }), {
|
||||
status: 422,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const { result } = mount();
|
||||
await act(() => result.current.remove(linked));
|
||||
expect(h.reportError).toHaveBeenCalledTimes(1);
|
||||
expect(h.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does say one it was left to handle", async () => {
|
||||
vi.spyOn(api, "deletePlaylist").mockRejectedValue(new HttpError(404, "gone"));
|
||||
const { result } = mount();
|
||||
await act(() => result.current.remove(linked));
|
||||
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ message: "gone" }));
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, HttpError, type Playlist } from "../lib/api";
|
||||
import { alreadyReported, api, HttpError, type Playlist } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
@@ -70,13 +70,15 @@ export function usePlaylistActions() {
|
||||
await api.deletePlaylist(pl.id, onYoutube);
|
||||
} catch (err) {
|
||||
// Prefer the server's specific reason (e.g. a local vs YouTube-side failure) over a blanket
|
||||
// "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side.
|
||||
notify({
|
||||
level: "warning",
|
||||
message:
|
||||
(err instanceof HttpError && err.detail) ||
|
||||
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
|
||||
});
|
||||
// "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side — unless
|
||||
// `req` already put it on screen (the error dialog, the connection notice): not twice.
|
||||
if (!alreadyReported(err))
|
||||
notify({
|
||||
level: "warning",
|
||||
message:
|
||||
(err instanceof HttpError && err.detail) ||
|
||||
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
|
||||
});
|
||||
}
|
||||
qc.removeQueries({ queryKey: qk.playlist(pl.id) });
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
"validation": "Some of the values you entered weren't valid. Please check them and try again.",
|
||||
"server": "Server error",
|
||||
"syncCooldown": "You've synced a few times just now — give it a few minutes. The YouTube budget is shared with everyone on this instance, and background syncing keeps running either way.",
|
||||
"notFound": {
|
||||
"generic": "This item doesn't exist any more.",
|
||||
"view": "This view doesn't exist any more.",
|
||||
"tag": "This tag doesn't exist any more.",
|
||||
"pin": "This isn't on your comfort shelf any more.",
|
||||
"playlist": "This playlist doesn't exist any more.",
|
||||
"move_target": "The playlist you're moving it to doesn't exist any more.",
|
||||
"video": "This video isn't in the library any more.",
|
||||
"invite": "This invite doesn't exist any more.",
|
||||
"user": "This user doesn't exist any more."
|
||||
},
|
||||
"ok": "OK",
|
||||
"offline": {
|
||||
"title": "Connection lost",
|
||||
|
||||
@@ -78,7 +78,14 @@
|
||||
"moveFailed": "Couldn't move the video.",
|
||||
"removeFailed": "Couldn't remove the video.",
|
||||
"errors": {
|
||||
"not_found": "This playlist doesn't exist any more."
|
||||
"name_required": "Give the playlist a name.",
|
||||
"youtube_read_scope": "Connect YouTube (read access) to sync your playlists.",
|
||||
"not_on_youtube": "This playlist isn't on YouTube.",
|
||||
"youtube_failed": "YouTube didn't accept that just now. Please try again.",
|
||||
"watch_later_not_pushable": "The Watch later list can't be sent to YouTube.",
|
||||
"watch_later_undeletable": "The Watch later list can't be deleted.",
|
||||
"youtube_editing_off": "Enable YouTube editing in Settings first.",
|
||||
"quota_exceeded": "Not enough YouTube quota left today for this sync."
|
||||
},
|
||||
"moveErrors": {
|
||||
"not_in_playlist": "That video isn't in this playlist any more — it changed somewhere else.",
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
"validation": "Néhány megadott érték érvénytelen volt. Ellenőrizd, majd próbáld újra.",
|
||||
"server": "Szerverhiba",
|
||||
"syncCooldown": "Az imént többször is szinkronizáltál — várj pár percet. A YouTube-keret közös az instance összes felhasználójával, a háttérszinkron pedig így is fut tovább.",
|
||||
"notFound": {
|
||||
"generic": "Ez az elem már nem létezik.",
|
||||
"view": "Ez a nézet már nem létezik.",
|
||||
"tag": "Ez a címke már nem létezik.",
|
||||
"pin": "Ez már nincs a komfort polcodon.",
|
||||
"playlist": "Ez a lejátszási lista már nem létezik.",
|
||||
"move_target": "A lejátszási lista, ahová áthelyeznéd, már nem létezik.",
|
||||
"video": "Ez a videó már nincs a könyvtárban.",
|
||||
"invite": "Ez a meghívó már nem létezik.",
|
||||
"user": "Ez a felhasználó már nem létezik."
|
||||
},
|
||||
"ok": "OK",
|
||||
"offline": {
|
||||
"title": "Megszakadt a kapcsolat",
|
||||
|
||||
@@ -78,7 +78,14 @@
|
||||
"moveFailed": "Nem sikerült áthelyezni a videót.",
|
||||
"removeFailed": "Nem sikerült eltávolítani a videót.",
|
||||
"errors": {
|
||||
"not_found": "Ez a lejátszási lista már nem létezik."
|
||||
"name_required": "Adj nevet a lejátszási listának.",
|
||||
"youtube_read_scope": "A listák szinkronizálásához kapcsold össze a YouTube-ot (olvasási hozzáférés).",
|
||||
"not_on_youtube": "Ez a lejátszási lista nincs fent a YouTube-on.",
|
||||
"youtube_failed": "A YouTube most nem fogadta el. Próbáld újra.",
|
||||
"watch_later_not_pushable": "A „Megnézem később” listát nem lehet a YouTube-ra küldeni.",
|
||||
"watch_later_undeletable": "A „Megnézem később” lista nem törölhető.",
|
||||
"youtube_editing_off": "Előbb engedélyezd a YouTube-szerkesztést a Beállításokban.",
|
||||
"quota_exceeded": "Mára nem maradt elég YouTube-keret ehhez a szinkronhoz."
|
||||
},
|
||||
"moveErrors": {
|
||||
"not_in_playlist": "Ez a videó már nincs ebben a listában — időközben máshol megváltozott.",
|
||||
|
||||
@@ -6,9 +6,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
// Spy on the toast sink so we can assert exactly what each error maps to.
|
||||
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
|
||||
|
||||
import { HttpError } from "./api";
|
||||
import { HttpError, api } from "./api";
|
||||
import { notify } from "./notifications";
|
||||
import { notifyCollectionEditError } from "./actionErrors";
|
||||
import { notifyActionError, notifyCollectionEditError } from "./actionErrors";
|
||||
|
||||
const notifyMock = vi.mocked(notify);
|
||||
beforeEach(() => notifyMock.mockClear());
|
||||
@@ -29,3 +29,25 @@ describe("notifyCollectionEditError", () => {
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "EDIT_FAILED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifyActionError", () => {
|
||||
it("does not toast a fault the request layer already put on screen", async () => {
|
||||
// A 500 opens the blocking error dialog inside `req`; the watched toggles, the saved views and a
|
||||
// playlist delete then toasted the same failure as well.
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: "boom" }), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const err = await api.plexSetState("1", "watched").catch((e: unknown) => e);
|
||||
notifyMock.mockClear();
|
||||
notifyActionError(err, "FAILED");
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does toast one the caller was left to handle", () => {
|
||||
notifyActionError(new HttpError(404, "gone"), "FAILED");
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "gone" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { HttpError } from "./api";
|
||||
// From the core, not the `./api` barrel: these two are pure (no request), and a test that mocks the
|
||||
// barrel wholesale to stub its requests must not take the error path's own plumbing with it.
|
||||
import { alreadyReported, HttpError } from "./api/core";
|
||||
import { notify } from "./notifications";
|
||||
|
||||
/**
|
||||
@@ -9,6 +11,10 @@ import { notify } from "./notifications";
|
||||
* for its non-403 case.
|
||||
*/
|
||||
export function notifyActionError(err: unknown, fallbackMessage: string): void {
|
||||
// Already on screen (`req` opened the error dialog, or raised the connection notice): a toast on
|
||||
// top reported one fault twice. Playlists filtered this by status by hand; the watched toggles,
|
||||
// the saved views and a playlist delete did not.
|
||||
if (alreadyReported(err)) return;
|
||||
const detail = err instanceof HttpError ? err.detail : null;
|
||||
notify({ level: "error", message: detail || fallbackMessage });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// A new endpoint therefore has nowhere to land except a domain module — the interleave that
|
||||
// motivated R7.5 cannot re-grow here. (An automated guard for that invariant lands in S3.)
|
||||
import {
|
||||
alreadyReported,
|
||||
getActiveAccount,
|
||||
HttpError,
|
||||
setActiveAccount,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
|
||||
// Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged.
|
||||
export {
|
||||
alreadyReported,
|
||||
HttpError,
|
||||
getActiveAccount,
|
||||
setActiveAccount,
|
||||
|
||||
@@ -21,7 +21,7 @@ vi.mock("../activeAccount", () => ({
|
||||
notifyActiveAccountChange: vi.fn(),
|
||||
}));
|
||||
|
||||
import { req, uploadReq, setUnauthorizedHandler, HttpError } from "./core";
|
||||
import { req, uploadReq, setUnauthorizedHandler, HttpError, alreadyReported } from "./core";
|
||||
|
||||
// A fake XHR whose lifecycle the test drives: capture the instance, set status/responseText, then
|
||||
// invoke onload/onerror/abort by hand.
|
||||
@@ -123,15 +123,68 @@ describe("req — a structured refusal reads in the user's language", () => {
|
||||
// The 404 every playlist route answers when the list was deleted in another tab. It used to be a
|
||||
// bare "Unknown playlist" string, shown verbatim under the Hungarian UI.
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: { code: "playlist", reason: "not_found" } }), {
|
||||
new Response(JSON.stringify({ detail: { code: "not_found", reason: "playlist" } }), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
await expect(req("/api/playlists/7/items/v1", { method: "DELETE" })).rejects.toMatchObject({
|
||||
status: 404,
|
||||
detail: "playlists.errors.not_found",
|
||||
detail: "errors.notFound.playlist",
|
||||
});
|
||||
expect(reportError).not.toHaveBeenCalled(); // a 404 is the caller's to report
|
||||
});
|
||||
});
|
||||
|
||||
describe("req — what it has already put in front of the user", () => {
|
||||
// A caller that toasts a failed action on top of the dialog `req` already opened reported one fault
|
||||
// twice. `alreadyReported` is how `notifyActionError` tells the two apart.
|
||||
const answer = (status: number) =>
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: "x" }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
it.each([500, 422, 409, 400])("marks a %s it raised the error dialog for", async (status) => {
|
||||
answer(status);
|
||||
const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e);
|
||||
expect(reportError).toHaveBeenCalled();
|
||||
expect(alreadyReported(err)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([404, 403, 429])("leaves a %s to the caller", async (status) => {
|
||||
answer(status);
|
||||
const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(HttpError);
|
||||
expect(alreadyReported(err)).toBe(false);
|
||||
});
|
||||
|
||||
it("marks a 401 the app shell handles (it signs the tab out)", async () => {
|
||||
answer(401);
|
||||
const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e);
|
||||
expect(alreadyReported(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("marks a request that never reached the server (the connection notice says it)", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e);
|
||||
expect(notify).toHaveBeenCalled();
|
||||
expect(alreadyReported(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("marks what the upload path reported the same way", async () => {
|
||||
const dead = uploadReq("/u", form());
|
||||
FakeXHR.last.onerror!();
|
||||
expect(alreadyReported(await dead.catch((e: unknown) => e))).toBe(true);
|
||||
const refused = uploadReq("/u", form());
|
||||
FakeXHR.last.status = 500;
|
||||
FakeXHR.last.onload!();
|
||||
expect(alreadyReported(await refused.catch((e: unknown) => e))).toBe(true);
|
||||
const gone = uploadReq("/u", form());
|
||||
FakeXHR.last.status = 404;
|
||||
FakeXHR.last.onload!();
|
||||
expect(alreadyReported(await gone.catch((e: unknown) => e))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,14 @@ import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "
|
||||
* 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 — the blocking error dialog, or the connection
|
||||
// notice. 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.
|
||||
const reported = new WeakSet<object>();
|
||||
export function alreadyReported(err: unknown): boolean {
|
||||
return typeof err === "object" && err !== null && reported.has(err);
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
status: number;
|
||||
detail?: string; // server-provided reason (FastAPI's `detail`), when present
|
||||
@@ -82,8 +90,13 @@ function localizeDetail(d: unknown): string {
|
||||
// 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"));
|
||||
// Any playlist route whose list is gone (deleted in another tab) — or was never the caller's.
|
||||
// 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"));
|
||||
// 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");
|
||||
@@ -223,6 +236,7 @@ export async function req<T = unknown>(
|
||||
continue;
|
||||
}
|
||||
markConnectivityLost();
|
||||
if (typeof e === "object" && e !== null) reported.add(e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -257,20 +271,26 @@ export async function req<T = unknown>(
|
||||
// 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.
|
||||
const err = new HttpError(r.status, detail, detailData);
|
||||
if (RETRIABLE_GATEWAY.has(r.status)) {
|
||||
markConnectivityLost();
|
||||
reported.add(err);
|
||||
} 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.
|
||||
// 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 (cfg.quiet) {
|
||||
/* caller handles the error inline — no global modal */
|
||||
} else if (r.status >= 500) {
|
||||
reportError(detail || `${i18n.t("errors.server")} (${r.status})`);
|
||||
reported.add(err);
|
||||
} else if (r.status === 400 || r.status === 409 || r.status === 422) {
|
||||
reportError(detail);
|
||||
reported.add(err);
|
||||
}
|
||||
throw new HttpError(r.status, detail, detailData);
|
||||
throw err;
|
||||
}
|
||||
// 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.
|
||||
@@ -326,16 +346,21 @@ export function uploadReq<T = unknown>(
|
||||
} catch {
|
||||
/* no JSON body */
|
||||
}
|
||||
const err = new HttpError(xhr.status, detail, detailData);
|
||||
if (xhr.status === 401) onUnauthorized?.();
|
||||
else if (xhr.status >= 500)
|
||||
reportError(detail || `${i18n.t("errors.server")} (${xhr.status})`);
|
||||
else if (xhr.status === 400 || xhr.status === 409 || xhr.status === 422) reportError(detail);
|
||||
reject(new HttpError(xhr.status, detail, detailData));
|
||||
if (xhr.status === 401 || xhr.status >= 500 || [400, 409, 422].includes(xhr.status))
|
||||
reported.add(err); // as in `req`: already on screen, not the caller's to toast again
|
||||
reject(err);
|
||||
};
|
||||
// A network-level failure (server unreachable) — same soft "connection lost" status as `req`.
|
||||
xhr.onerror = () => {
|
||||
markConnectivityLost();
|
||||
reject(new HttpError(0, i18n.t("errors.offline.body")));
|
||||
const err = new HttpError(0, i18n.t("errors.offline.body"));
|
||||
reported.add(err);
|
||||
reject(err);
|
||||
};
|
||||
xhr.onabort = () => reject(new HttpError(0, "aborted"));
|
||||
xhr.send(form);
|
||||
|
||||
Reference in New Issue
Block a user