Merge improvement/connection-lost-self-resolving: honour the 'clears once it's back' promise
This commit is contained in:
commit
0ab6742b44
5 changed files with 50 additions and 20 deletions
|
|
@ -3,6 +3,10 @@
|
||||||
"generic": "Der Server konnte die Aktion nicht ausführen. Bitte erneut versuchen.",
|
"generic": "Der Server konnte die Aktion nicht ausführen. Bitte erneut versuchen.",
|
||||||
"server": "Serverfehler",
|
"server": "Serverfehler",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
|
"offline": {
|
||||||
|
"title": "Verbindung verloren",
|
||||||
|
"body": "Server nicht erreichbar — er startet möglicherweise gerade neu. Dieser Hinweis verschwindet, sobald die Verbindung wieder steht."
|
||||||
|
},
|
||||||
"boundary": {
|
"boundary": {
|
||||||
"title": "Etwas ist schiefgelaufen.",
|
"title": "Etwas ist schiefgelaufen.",
|
||||||
"subtitle": "Die App ist auf einen unerwarteten Fehler gestoßen.",
|
"subtitle": "Die App ist auf einen unerwarteten Fehler gestoßen.",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
"generic": "The server couldn't carry out that action. Please try again.",
|
"generic": "The server couldn't carry out that action. Please try again.",
|
||||||
"server": "Server error",
|
"server": "Server error",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
|
"offline": {
|
||||||
|
"title": "Connection lost",
|
||||||
|
"body": "Couldn't reach the server — it may be restarting. This notice will clear once the connection is back."
|
||||||
|
},
|
||||||
"boundary": {
|
"boundary": {
|
||||||
"title": "Something went wrong.",
|
"title": "Something went wrong.",
|
||||||
"subtitle": "The app hit an unexpected error.",
|
"subtitle": "The app hit an unexpected error.",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
"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.",
|
||||||
"server": "Szerverhiba",
|
"server": "Szerverhiba",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
|
"offline": {
|
||||||
|
"title": "Megszakadt a kapcsolat",
|
||||||
|
"body": "Nem sikerült elérni a szervert — lehet, hogy épp újraindul. Ez az üzenet eltűnik, amint helyreáll a kapcsolat."
|
||||||
|
},
|
||||||
"boundary": {
|
"boundary": {
|
||||||
"title": "Hiba történt.",
|
"title": "Hiba történt.",
|
||||||
"subtitle": "Az alkalmazás váratlan hibába ütközött.",
|
"subtitle": "Az alkalmazás váratlan hibába ütközött.",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { notify } from "./notifications";
|
import { notify, remove } from "./notifications";
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
import { reportError } from "./errorDialog";
|
import { reportError } from "./errorDialog";
|
||||||
|
|
||||||
|
|
@ -163,14 +163,24 @@ class HttpError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collapse bursts of connection failures (e.g. while the server restarts) into a
|
// A single, self-resolving "connection lost" status: while the server is unreachable we
|
||||||
// single notification rather than one per failed request.
|
// show one sticky, transient (non-persisted) notice; the moment any request reaches the
|
||||||
let lastErrorNotifiedAt = 0;
|
// server again we remove it. This makes good on its "clears once it's back" copy and keeps
|
||||||
function notifyErrorThrottled(title: string, message: string): void {
|
// bursts of concurrent failures from stacking up — there's only ever one live handle.
|
||||||
const now = Date.now();
|
let connectivityNotifId: number | null = null;
|
||||||
if (now - lastErrorNotifiedAt < 30_000) return;
|
function markConnectivityLost(): void {
|
||||||
lastErrorNotifiedAt = now;
|
if (connectivityNotifId !== null) return;
|
||||||
notify({ level: "error", title, message });
|
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
|
// Gateway statuses that mean "the upstream connection failed", not "the app rejected the
|
||||||
|
|
@ -206,10 +216,7 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
|
||||||
await delay(250);
|
await delay(250);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
notifyErrorThrottled(
|
markConnectivityLost();
|
||||||
"Connection lost",
|
|
||||||
"Couldn't reach the server — it may be restarting. This will clear once it's back."
|
|
||||||
);
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -221,6 +228,10 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
|
||||||
continue;
|
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) {
|
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;
|
||||||
|
|
@ -236,10 +247,7 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
|
||||||
// A genuine 500 (real fault, carries a JSON detail) still gets the modal, as do
|
// 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.
|
// 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow.
|
||||||
if (RETRIABLE_GATEWAY.has(r.status)) {
|
if (RETRIABLE_GATEWAY.has(r.status)) {
|
||||||
notifyErrorThrottled(
|
markConnectivityLost();
|
||||||
"Connection lost",
|
|
||||||
"Couldn't reach the server — it may be restarting. This will clear once it's back."
|
|
||||||
);
|
|
||||||
} else if (r.status >= 500) {
|
} else if (r.status >= 500) {
|
||||||
reportError(detail || `${i18n.t("errors.server")} (${r.status})`);
|
reportError(detail || `${i18n.t("errors.server")} (${r.status})`);
|
||||||
} else if (r.status === 400 || r.status === 409 || r.status === 422) {
|
} else if (r.status === 400 || r.status === 409 || r.status === 422) {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ export interface Notification {
|
||||||
ts: number;
|
ts: number;
|
||||||
read: boolean;
|
read: boolean;
|
||||||
dismissed: boolean; // transient toast surface closed (still kept in history)
|
dismissed: boolean; // transient toast surface closed (still kept in history)
|
||||||
|
// Live session-only status (e.g. "connection lost"): sticky toast (no auto-dismiss
|
||||||
|
// timer) that the producer removes itself when the condition resolves. Never persisted
|
||||||
|
// to history, so a reload can't orphan it with no live handle to clear it.
|
||||||
|
transient: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotifyInput {
|
export interface NotifyInput {
|
||||||
|
|
@ -48,6 +52,7 @@ export interface NotifyInput {
|
||||||
meta?: NotifMeta;
|
meta?: NotifMeta;
|
||||||
requiresInteraction?: boolean;
|
requiresInteraction?: boolean;
|
||||||
sound?: boolean; // force the alert tone (when sound is enabled) even for an info toast
|
sound?: boolean; // force the alert tone (when sound is enabled) even for an info toast
|
||||||
|
transient?: boolean; // live status: sticky toast, not persisted (see Notification.transient)
|
||||||
}
|
}
|
||||||
|
|
||||||
const HISTORY_KEY = "subfeed.notifications";
|
const HISTORY_KEY = "subfeed.notifications";
|
||||||
|
|
@ -122,7 +127,7 @@ function load(): Notification[] {
|
||||||
// resurrect as active toasts on reload — their auto-dismiss timers aren't
|
// resurrect as active toasts on reload — their auto-dismiss timers aren't
|
||||||
// re-armed across a reload, so otherwise they'd stick forever. Also drop the
|
// re-armed across a reload, so otherwise they'd stick forever. Also drop the
|
||||||
// live `action` callback, which can't survive serialization.
|
// live `action` callback, which can't survive serialization.
|
||||||
return raw.map((n) => ({ ...n, action: undefined, dismissed: true }) as Notification);
|
return raw.map((n) => ({ ...n, action: undefined, dismissed: true, transient: false }) as Notification);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -130,7 +135,9 @@ function load(): Notification[] {
|
||||||
|
|
||||||
function persist() {
|
function persist() {
|
||||||
try {
|
try {
|
||||||
const slim = items.map(({ action: _action, ...n }) => n);
|
// Transient status notices are live-session only — never write them to history,
|
||||||
|
// so a reload can't leave one stranded with no producer left to clear it.
|
||||||
|
const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n);
|
||||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(slim));
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(slim));
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore quota / serialization errors */
|
/* ignore quota / serialization errors */
|
||||||
|
|
@ -164,7 +171,9 @@ export function notify(input: NotifyInput): number {
|
||||||
const requiresInteraction = input.requiresInteraction ?? false;
|
const requiresInteraction = input.requiresInteraction ?? false;
|
||||||
const level = input.level ?? "info";
|
const level = input.level ?? "info";
|
||||||
// config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed).
|
// config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed).
|
||||||
const duration = requiresInteraction
|
// A transient status stays put until its producer clears it (no auto-dismiss timer),
|
||||||
|
// same as an interaction-awaiting notice.
|
||||||
|
const duration = requiresInteraction || input.transient
|
||||||
? undefined
|
? undefined
|
||||||
: level === "error" || level === "fatal"
|
: level === "error" || level === "fatal"
|
||||||
? ERROR_TTL
|
? ERROR_TTL
|
||||||
|
|
@ -185,6 +194,7 @@ export function notify(input: NotifyInput): number {
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
read: false,
|
read: false,
|
||||||
dismissed: false,
|
dismissed: false,
|
||||||
|
transient: input.transient ?? false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
emit();
|
emit();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue