~150 HTTPExceptions carried a hand-written English sentence as their detail,
shown verbatim under the Hungarian UI — in the blocking error dialog and on
every inline surface that printed HttpError.detail (sign-in, setup wizard,
change password, the passphrase gate, live search). Four also quoted an
upstream exception's text back to the user.
- app/refusal.py: `refused(status, reason, **params)` → {code: "refused",
reason, …params}; localizeDetail translates errors.refused.<reason> with the
params interpolated. "Unknown X" 404s use the existing not_found(what).
- sysconfig.set_value raises InvalidSetting (reason + bounds) instead of an
English ValueError; the three writers answer it through setting_refused.
- The Plex connection/write/image failures log what the upstream said and
answer a reason; the search scraper's failure is logged and answered as
youtube/unavailable (it crashed refusal_reason before).
- HttpError.detail now only ever holds a translated reason: a string detail
(only the framework's own can remain) is left out, so reading .detail is
safe everywhere — including destructured, the hole the raw-detail source
scan could not see. That scan and its five-file exception list are gone.
- Enforced: test_http_details.py fails on any HTTPException with a sentence
for a detail; the contract test requires every refused reason and every
not_found "what" in both languages, and no key for one that is gone.
- local/jsx-comment-text: a // inside JSX children renders as text; the rule
is ten lines in eslint.config.js rather than eslint-plugin-react's ~1800
lockfile lines.
65 lines
3.2 KiB
TypeScript
65 lines
3.2 KiB
TypeScript
// From the core, not the `./api` barrel: these 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, isAborted, markReported } from "./api/core";
|
||
import { notify } from "./notifications";
|
||
import i18n from "../i18n";
|
||
|
||
const GATEWAY = new Set([502, 503, 504]);
|
||
|
||
/**
|
||
* WHY a write failed, in the reader's language — the line under the toast's title.
|
||
*
|
||
* The server's own reason wins: `req` has already run the structured detail through
|
||
* `localizeDetail`, and it is the most specific thing there is to say (quota numbers, which row is
|
||
* gone). Every refusal the app answers is structured now (backend `app/refusal.py`), and a string
|
||
* detail never reaches `HttpError.detail` (see there) — the hand-written English that once put
|
||
* "No user with that email." into a Hungarian UI has nowhere left to come from. `detailData` still
|
||
* gates it: status-0 errors carry a local sentinel in `detail`, not a reason. Otherwise the status
|
||
* says it.
|
||
*/
|
||
export function failureReason(err: unknown): string {
|
||
const t = i18n.t.bind(i18n);
|
||
if (err instanceof HttpError) {
|
||
if (err.detailData != null && err.detail) return err.detail;
|
||
// 0: `uploadReq`'s network failure. 502–504: the gateway could not reach the app. Either way the
|
||
// change did not go through, which the connection notice alone does not say.
|
||
if (err.status === 0 || GATEWAY.has(err.status)) return t("errors.reason.unreachable");
|
||
if (err.status === 403) return t("errors.reason.forbidden");
|
||
if (err.status === 404) return t("errors.notFound.generic");
|
||
if (err.status === 429) return t("errors.reason.rateLimited");
|
||
return t("errors.generic");
|
||
}
|
||
// `fetch` rejects with a TypeError when the request never reached the server.
|
||
if (err instanceof TypeError) return t("errors.reason.unreachable");
|
||
return t("errors.reason.unexpected");
|
||
}
|
||
|
||
/**
|
||
* Surface a failed write as an error toast instead of the click silently doing nothing: the title
|
||
* names WHAT failed (the caller's translated "Couldn't hide the channel"), the line under it says
|
||
* WHY (`failureReason`). Domain-agnostic — the one way every action failure is reported, including
|
||
* the app-wide mutation reporter in lib/queryClient.
|
||
*/
|
||
export function notifyActionError(
|
||
err: unknown,
|
||
title: string,
|
||
level: "error" | "warning" = "error",
|
||
): void {
|
||
// Already on screen (`req` opened the error dialog, the tab is being signed out, or a caller's own
|
||
// `onError` got there first): a second toast reported one fault twice. A cancel is not a failure.
|
||
if (alreadyReported(err) || isAborted(err)) return;
|
||
notify({ level, title, message: failureReason(err) });
|
||
markReported(err);
|
||
}
|
||
|
||
/**
|
||
* A failure the caller has a SPECIFIC sentence for, because it read the error's own code ("this
|
||
* instance cannot send email", "someone registered on that address") — shown as it is, and marked,
|
||
* so the app-wide mutation reporter does not put a second, generic toast next to it.
|
||
*/
|
||
export function notifyKnownFailure(err: unknown, message: string): void {
|
||
if (alreadyReported(err)) return;
|
||
notify({ level: "error", message });
|
||
markReported(err);
|
||
}
|