fix(api): a write that hit a connection failure is still reported, and every failure is reported once
Deep-review round 4, the "one report per failure" contract round 3 introduced. - `req` marked gateway (502/503/504) and network failures "already reported" because they raise the connection notice. But that notice is about connectivity and removes itself on the very next answer — which every caller's own resync refetch fetches at once — so a delete that hit a keepalive reset flashed the notice and was then reported nowhere, the list still there. Only the durable reports (the error dialog, the 401 sign-out) count now; the caller says the action failed. - A connection notice the user closed swallowed every later one for the rest of the visit; it is raised again. - `req` and `uploadReq` share one failure policy (`reportFailure`). The upload copy had drifted: a 502 opened the blocking "Server error (502)" dialog and cleared a notice that was still true. - A taken Plex collection name is a structured 409, localized, and the create/rename calls are quiet: it was an English dialog plus a Hungarian toast. `notifyCollectionEditError` is gone. - The mutation onErrors that toasted unconditionally after a non-quiet request (invite approve / deny / add, demo whitelist, audit clear, scheduler, share, full history, edit, config tests, tag attach) go through `notifyActionError`; the playlist delete's hand-rolled copy of it too (it takes a level now). The user role / suspend / delete actions had no onError at all: a user deleted in another tab failed silently and stayed listed — now said, and the list refetched.
This commit is contained in:
@@ -6,14 +6,15 @@
|
||||
// each lifecycle branch.
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
const { reportError, notify, remove } = vi.hoisted(() => ({
|
||||
const { reportError, notify, remove, getActiveToasts } = vi.hoisted(() => ({
|
||||
reportError: vi.fn(),
|
||||
notify: vi.fn(() => 1),
|
||||
remove: vi.fn(),
|
||||
getActiveToasts: vi.fn((): { id: number }[] => []),
|
||||
}));
|
||||
|
||||
vi.mock("../errorDialog", () => ({ reportError }));
|
||||
vi.mock("../notifications", () => ({ notify, remove }));
|
||||
vi.mock("../notifications", () => ({ notify, remove, getActiveToasts }));
|
||||
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
|
||||
vi.mock("../activeAccount", () => ({
|
||||
activeAccountId: () => null,
|
||||
@@ -54,6 +55,9 @@ class FakeXHR {
|
||||
beforeEach(() => {
|
||||
reportError.mockReset();
|
||||
notify.mockReset();
|
||||
remove.mockReset();
|
||||
getActiveToasts.mockReset();
|
||||
getActiveToasts.mockReturnValue([]);
|
||||
setUnauthorizedHandler(null);
|
||||
vi.stubGlobal("XMLHttpRequest", FakeXHR as unknown as typeof XMLHttpRequest);
|
||||
});
|
||||
@@ -167,17 +171,45 @@ describe("req — what it has already put in front of the user", () => {
|
||||
expect(alreadyReported(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("marks a request that never reached the server (the connection notice says it)", async () => {
|
||||
// The connection notice says the server is unreachable — not that THIS action failed — and it
|
||||
// removes itself on the very next answer (the caller's own resync refetch). Marked as reported, a
|
||||
// delete that hit a keepalive reset flashed the notice and was then reported nowhere at all.
|
||||
it("raises the connection notice for a request that never reached the server, and leaves the failed action to the caller", 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);
|
||||
expect(alreadyReported(err)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([502, 503, 504])(
|
||||
"raises the connection notice for a %s, and leaves the failed action to the caller",
|
||||
async (status) => {
|
||||
answer(status);
|
||||
const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e);
|
||||
expect(notify).toHaveBeenCalled();
|
||||
expect(reportError).not.toHaveBeenCalled();
|
||||
expect(err).toMatchObject({ status });
|
||||
expect(alreadyReported(err)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("raises the connection notice again after the user closed it", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
notify.mockReturnValue(7);
|
||||
await req("/api/x", { method: "POST" }).catch(() => {});
|
||||
getActiveToasts.mockReturnValue([{ id: 7 }]); // still on screen: one notice, not a stack
|
||||
notify.mockClear();
|
||||
await req("/api/x", { method: "POST" }).catch(() => {});
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
getActiveToasts.mockReturnValue([]); // closed with its ✕
|
||||
await req("/api/x", { method: "POST" }).catch(() => {});
|
||||
expect(notify).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
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);
|
||||
expect(alreadyReported(await dead.catch((e: unknown) => e))).toBe(false);
|
||||
const refused = uploadReq("/u", form());
|
||||
FakeXHR.last.status = 500;
|
||||
FakeXHR.last.onload!();
|
||||
@@ -187,4 +219,20 @@ describe("req — what it has already put in front of the user", () => {
|
||||
FakeXHR.last.onload!();
|
||||
expect(alreadyReported(await gone.catch((e: unknown) => e))).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a gateway error on the upload path as the connection notice, not a blocking dialog", async () => {
|
||||
// The upload path kept its own copy of this policy, and it had drifted: a 502 opened the
|
||||
// blocking "Server error (502)" dialog and cleared a connection notice that was still true.
|
||||
notify.mockReturnValue(9);
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
await req("/api/x", { method: "POST" }).catch(() => {}); // a notice is up
|
||||
getActiveToasts.mockReturnValue([{ id: 9 }]);
|
||||
remove.mockClear();
|
||||
const p = uploadReq("/u", form());
|
||||
FakeXHR.last.status = 502;
|
||||
FakeXHR.last.onload!();
|
||||
await expect(p).rejects.toMatchObject({ status: 502 });
|
||||
expect(reportError).not.toHaveBeenCalled();
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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 { getActiveToasts, notify, remove } from "../notifications";
|
||||
import i18n from "../../i18n";
|
||||
import { formatBytes } from "../format";
|
||||
import { reportError } from "../errorDialog";
|
||||
@@ -14,9 +14,15 @@ 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.
|
||||
// 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).
|
||||
const reported = new WeakSet<object>();
|
||||
export function alreadyReported(err: unknown): boolean {
|
||||
return typeof err === "object" && err !== null && reported.has(err);
|
||||
@@ -90,6 +96,11 @@ 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"));
|
||||
// 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");
|
||||
// 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
|
||||
@@ -109,7 +120,11 @@ function localizeDetail(d: unknown): string {
|
||||
// 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;
|
||||
// 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"),
|
||||
@@ -129,6 +144,35 @@ function markConnectivityRestored(): void {
|
||||
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.
|
||||
@@ -235,8 +279,7 @@ export async function req<T = unknown>(
|
||||
await delay(250);
|
||||
continue;
|
||||
}
|
||||
markConnectivityLost();
|
||||
if (typeof e === "object" && e !== null) reported.add(e);
|
||||
markConnectivityLost(); // the connection notice; the failed action is still the caller's
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -266,30 +309,8 @@ export async function req<T = unknown>(
|
||||
} 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.
|
||||
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 —
|
||||
// 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);
|
||||
}
|
||||
reportFailure(err, !!cfg.quiet);
|
||||
throw err;
|
||||
}
|
||||
// The single audited cast in this layer: the server contract is asserted here, once, so
|
||||
@@ -325,7 +346,7 @@ export function uploadReq<T = unknown>(
|
||||
cfg.signal.addEventListener("abort", () => xhr.abort());
|
||||
}
|
||||
xhr.onload = () => {
|
||||
markConnectivityRestored();
|
||||
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);
|
||||
@@ -347,20 +368,13 @@ export function uploadReq<T = unknown>(
|
||||
/* 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);
|
||||
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
|
||||
reportFailure(err, false);
|
||||
reject(err);
|
||||
};
|
||||
// A network-level failure (server unreachable) — same soft "connection lost" status as `req`.
|
||||
xhr.onerror = () => {
|
||||
markConnectivityLost();
|
||||
const err = new HttpError(0, i18n.t("errors.offline.body"));
|
||||
reported.add(err);
|
||||
reject(err);
|
||||
reject(new HttpError(0, i18n.t("errors.offline.body")));
|
||||
};
|
||||
xhr.onabort = () => reject(new HttpError(0, "aborted"));
|
||||
xhr.send(form);
|
||||
|
||||
@@ -505,15 +505,19 @@ export const plexApi = {
|
||||
return req(`/api/plex/collections/${encodeURIComponent(rk)}${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
// --- Collection editing (admin only; writes back to Plex) ---
|
||||
// `quiet`: the editor reports a failure itself, as a toast beside the name being typed (a taken
|
||||
// name comes back structured and localized — see localizeDetail). Not quiet, it was reported twice:
|
||||
// the global error dialog in English AND the editor's toast.
|
||||
plexCreateCollection: (
|
||||
library: string,
|
||||
title: string,
|
||||
item_rating_key: string,
|
||||
): Promise<PlexCollection> =>
|
||||
req(`/api/plex/collections`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ library, title, item_rating_key }),
|
||||
}),
|
||||
req(
|
||||
`/api/plex/collections`,
|
||||
{ method: "POST", body: JSON.stringify({ library, title, item_rating_key }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
plexCollectionAddItem: (rk: string, itemRk: string): Promise<PlexCollection> =>
|
||||
req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, {
|
||||
method: "POST",
|
||||
@@ -523,10 +527,11 @@ export const plexApi = {
|
||||
method: "DELETE",
|
||||
}),
|
||||
plexRenameCollection: (rk: string, title: string): Promise<PlexCollection> =>
|
||||
req(`/api/plex/collections/${encodeURIComponent(rk)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ title }),
|
||||
}),
|
||||
req(
|
||||
`/api/plex/collections/${encodeURIComponent(rk)}`,
|
||||
{ method: "PATCH", body: JSON.stringify({ title }) },
|
||||
{ quiet: true }, // as plexCreateCollection
|
||||
),
|
||||
plexDeleteCollection: (rk: string): Promise<{ deleted: string }> =>
|
||||
req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "DELETE" }),
|
||||
plexSetCollectionEditable: (rk: string, editable: boolean): Promise<PlexCollection> =>
|
||||
|
||||
Reference in New Issue
Block a user