feat(ui): in-session maintenance overlay on the edge 503 marker
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
The edge caddy 503 carries X-Scrabble-Maintenance during a deploy window, but a user already in the running SPA only sees their calls start failing — the static caddy page catches only a fresh load. Detect that marker (strictly — not any transient 'unavailable', which the Connecting indicator already covers) on the raw ConnectError at the two transport catch sites (unary exec + the live subscribe stream), before toGatewayError discards the response headers, and raise a non-dismissable dimmed overlay that mirrors the static caddy page. It self-clears: a capped-backoff poll (a cheap read, mirroring connection.svelte.ts) lifts it on the first success, and a manual "retry" button forces an immediate re-check — so it can never get stuck. On detection the read retry loop fails fast, so a window doesn't burn the retry budget on every call. - pure detector maintenance.ts (maintenanceRetryMs / parseRetryAfterMs) + unit tests; the store + self-clearing poll in maintenance.svelte.ts (mirrors connection.svelte.ts) - MaintenanceOverlay.svelte (clones Splash's fixed/inset/dimmed shell, non-dismissable), mounted app-global in App.svelte after Coachmark - transport.ts detects + reports at both catch sites, clears on any successful read - i18n RU "Технические работы" / EN "Under maintenance"; a window.__maint mock hook (the mock can't emit a real 503) + a Playwright spec Prod is same-origin (VITE_GATEWAY_URL empty) so the marker header is readable without a CORS expose-header. Verified: pnpm check (0), unit (402), build, e2e (186, Chromium+WebKit).
This commit is contained in:
+22
-3
@@ -12,6 +12,8 @@ import { GatewayError, type GatewayClient } from './client';
|
||||
import * as codec from './codec';
|
||||
import { browserOffset } from './profileValidation';
|
||||
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
||||
import { clearMaintenance, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
|
||||
import { maintenanceRetryMs } from './maintenance';
|
||||
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
||||
|
||||
const MAX_RETRIES = 6;
|
||||
@@ -28,10 +30,14 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
|
||||
// The reachability probe the connection watcher fires while offline: a cheap authenticated read
|
||||
// (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
|
||||
registerProbe(async () => {
|
||||
const reachabilityProbe = async (): Promise<void> => {
|
||||
if (!token) throw new Error('no session');
|
||||
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() });
|
||||
});
|
||||
};
|
||||
registerProbe(reachabilityProbe);
|
||||
// The maintenance overlay reuses the same cheap read to poll for the end of a deploy
|
||||
// window; the first success lifts the overlay (maintenance.svelte.ts).
|
||||
registerMaintenanceProbe(reachabilityProbe);
|
||||
|
||||
// exec runs one unary op, auto-retrying transient transport failures with capped backoff (so a
|
||||
// dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting
|
||||
@@ -43,6 +49,14 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers(), signal });
|
||||
} catch (e) {
|
||||
if (signal?.aborted) throw e; // an intentional cancel (e.g. the tiles moved) — do not retry
|
||||
const maintMs = maintenanceRetryMs(e);
|
||||
if (maintMs !== null) {
|
||||
// A planned deploy window (the edge 503 carried X-Scrabble-Maintenance): raise the
|
||||
// overlay and fail fast — its own poll drives recovery — instead of burning the
|
||||
// retry budget on every read for the length of the window.
|
||||
reportMaintenance(maintMs);
|
||||
throw toGatewayError(e);
|
||||
}
|
||||
const err = toGatewayError(e);
|
||||
if (retryable(err.code, messageType) && attempt < MAX_RETRIES) {
|
||||
reportOffline();
|
||||
@@ -53,6 +67,7 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
throw err;
|
||||
}
|
||||
reportOnline();
|
||||
clearMaintenance();
|
||||
if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
|
||||
return res.payload;
|
||||
}
|
||||
@@ -310,7 +325,11 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
if (pe) onEvent(pe);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!ctrl.signal.aborted) onError?.(toGatewayError(e));
|
||||
if (!ctrl.signal.aborted) {
|
||||
const maintMs = maintenanceRetryMs(e);
|
||||
if (maintMs !== null) reportMaintenance(maintMs);
|
||||
onError?.(toGatewayError(e));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => ctrl.abort();
|
||||
|
||||
Reference in New Issue
Block a user