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:
@@ -6,6 +6,7 @@ import type { GatewayClient } from './client';
|
||||
import { MockGateway } from './mock/client';
|
||||
import { createTransport } from './transport';
|
||||
import { reportOffline, reportOnline } from './connection.svelte';
|
||||
import { clearMaintenance, reportMaintenance } from './maintenance.svelte';
|
||||
|
||||
const isMock = import.meta.env.MODE === 'mock';
|
||||
|
||||
@@ -20,6 +21,12 @@ if (isMock && typeof window !== 'undefined') {
|
||||
offline: reportOffline,
|
||||
online: reportOnline,
|
||||
};
|
||||
// The mock never produces a real 503, so the e2e drives the maintenance overlay directly
|
||||
// (the store's poll is inert in mock — no probe is registered — so `off` clears it).
|
||||
(window as unknown as { __maint?: { on(retryAfterMs?: number): void; off(): void } }).__maint = {
|
||||
on: (retryAfterMs = 15000) => reportMaintenance(retryAfterMs),
|
||||
off: clearMaintenance,
|
||||
};
|
||||
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
|
||||
// attaches a robot on a timer).
|
||||
(
|
||||
|
||||
@@ -7,6 +7,9 @@ export const en = {
|
||||
'app.brand': 'Erudit',
|
||||
'dict.loading': 'Loading…',
|
||||
'connection.connecting': 'Connecting…',
|
||||
'maintenance.title': 'Under maintenance',
|
||||
'maintenance.body': 'Scrabble is briefly down for an update. It will be back in a moment — no need to reload the page.',
|
||||
'maintenance.retry': 'Try again',
|
||||
|
||||
'blocked.title': 'Account blocked',
|
||||
'blocked.permanent': 'Your account is blocked.',
|
||||
|
||||
@@ -8,6 +8,9 @@ export const ru: Record<MessageKey, string> = {
|
||||
'app.brand': 'Эрудит',
|
||||
'dict.loading': 'Загрузка…',
|
||||
'connection.connecting': 'Подключение…',
|
||||
'maintenance.title': 'Технические работы',
|
||||
'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.',
|
||||
'maintenance.retry': 'Повторить',
|
||||
|
||||
'blocked.title': 'Учётная запись заблокирована',
|
||||
'blocked.permanent': 'Ваша учётная запись заблокирована.',
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Global maintenance signal + self-clearing poll. `active` is true while the edge is in a
|
||||
// planned deploy window (a caddy 503 carried `X-Scrabble-Maintenance`; see maintenance.ts).
|
||||
// A non-dismissable overlay (MaintenanceOverlay.svelte) covers the app while active, and a
|
||||
// cheap read is retried on a capped-backoff cadence (mirroring connection.svelte.ts) — the
|
||||
// first success lifts the overlay, so it can never get stuck even with no other traffic.
|
||||
// Mock mode never reports maintenance, so it simply stays inactive.
|
||||
|
||||
import { backoffMs } from './retry';
|
||||
|
||||
let active = $state(false);
|
||||
let retryHintMs = $state(0);
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let attempt = 0;
|
||||
let probing = false;
|
||||
let probe: (() => Promise<void>) | null = null;
|
||||
|
||||
export const maintenance = {
|
||||
/** active is true while the edge is in a planned maintenance window. */
|
||||
get active(): boolean {
|
||||
return active;
|
||||
},
|
||||
/** retryHintMs is the last Retry-After hint in milliseconds (0 when unknown). */
|
||||
get retryHintMs(): number {
|
||||
return retryHintMs;
|
||||
},
|
||||
};
|
||||
|
||||
/** registerMaintenanceProbe installs the reachability read the poll fires while active
|
||||
* (the transport wires a cheap authenticated read). */
|
||||
export function registerMaintenanceProbe(fn: () => Promise<void>): void {
|
||||
probe = fn;
|
||||
}
|
||||
|
||||
/** reportMaintenance raises the overlay and starts the self-clearing poll. Idempotent: a
|
||||
* repeat hit only refreshes the hint, it never restarts a running poll. */
|
||||
export function reportMaintenance(retryAfterMs: number): void {
|
||||
retryHintMs = retryAfterMs;
|
||||
if (active) return;
|
||||
active = true;
|
||||
attempt = 0;
|
||||
if (probe) schedulePoll();
|
||||
}
|
||||
|
||||
/** clearMaintenance lowers the overlay and stops the poll (a successful read, or reset). */
|
||||
export function clearMaintenance(): void {
|
||||
active = false;
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** retryNow forces an immediate re-check (the overlay's button). It never closes the
|
||||
* overlay itself — only a successful probe does. A no-op while a probe is in flight. */
|
||||
export function retryNow(): void {
|
||||
if (!active || !probe || probing) return;
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
attempt = 0;
|
||||
runProbe();
|
||||
}
|
||||
|
||||
function schedulePoll(): void {
|
||||
pollTimer = setTimeout(runProbe, backoffMs(++attempt));
|
||||
}
|
||||
|
||||
function runProbe(): void {
|
||||
pollTimer = null;
|
||||
if (!active || !probe || probing) return;
|
||||
probing = true;
|
||||
probe().then(
|
||||
() => {
|
||||
probing = false;
|
||||
clearMaintenance();
|
||||
},
|
||||
() => {
|
||||
probing = false;
|
||||
if (active) schedulePoll();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Code, ConnectError } from '@connectrpc/connect';
|
||||
import { maintenanceRetryMs, parseRetryAfterMs } from './maintenance';
|
||||
|
||||
// A raw ConnectError as connect-web builds it from a non-Connect HTTP 503: the response
|
||||
// headers are preserved as `.metadata` (verified against @connectrpc/connect-web).
|
||||
const err503 = (headers: Record<string, string>): ConnectError =>
|
||||
new ConnectError('HTTP 503', Code.Unavailable, new Headers(headers));
|
||||
|
||||
describe('maintenanceRetryMs', () => {
|
||||
it('detects the marker and parses Retry-After', () => {
|
||||
expect(maintenanceRetryMs(err503({ 'x-scrabble-maintenance': '1', 'retry-after': '120' }))).toBe(120_000);
|
||||
});
|
||||
|
||||
it('matches the header case-insensitively, defaulting the delay when Retry-After is absent', () => {
|
||||
expect(maintenanceRetryMs(err503({ 'X-Scrabble-Maintenance': '1' }))).toBe(15_000);
|
||||
});
|
||||
|
||||
it('returns null for a 503 without the marker (a plain transient failure)', () => {
|
||||
expect(maintenanceRetryMs(err503({ 'retry-after': '30' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-Connect errors', () => {
|
||||
expect(maintenanceRetryMs(new Error('boom'))).toBeNull();
|
||||
expect(maintenanceRetryMs(undefined)).toBeNull();
|
||||
expect(maintenanceRetryMs('nope')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRetryAfterMs', () => {
|
||||
it('clamps into the 3s–120s band', () => {
|
||||
expect(parseRetryAfterMs('120')).toBe(120_000);
|
||||
expect(parseRetryAfterMs('1')).toBe(3_000); // floor
|
||||
expect(parseRetryAfterMs('9999')).toBe(120_000); // ceiling
|
||||
});
|
||||
|
||||
it('falls back to the default on absent / garbage / non-positive', () => {
|
||||
expect(parseRetryAfterMs(null)).toBe(15_000);
|
||||
expect(parseRetryAfterMs(undefined)).toBe(15_000);
|
||||
expect(parseRetryAfterMs('soon')).toBe(15_000);
|
||||
expect(parseRetryAfterMs('0')).toBe(15_000);
|
||||
expect(parseRetryAfterMs('-5')).toBe(15_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// Detection of a planned edge maintenance window. During a prod deploy roll the edge
|
||||
// caddy returns HTTP 503 with an `X-Scrabble-Maintenance: 1` header (and a `Retry-After`)
|
||||
// for every gated route. The SPA keys STRICTLY on that marker — not on any transport
|
||||
// `unavailable`, which the "Connecting…" indicator already covers — so a planned window
|
||||
// raises the maintenance overlay while an ordinary blip stays a soft reconnect.
|
||||
//
|
||||
// These helpers are pure (no DOM, no runes) so they unit-test in the node vitest env. The
|
||||
// maintenance marker + Retry-After ride on the raw ConnectError's `metadata`, which
|
||||
// toGatewayError (retry.ts) discards — so detection must run on the raw error.
|
||||
|
||||
import { ConnectError } from '@connectrpc/connect';
|
||||
|
||||
const DEFAULT_RETRY_MS = 15_000;
|
||||
const MIN_RETRY_MS = 3_000;
|
||||
const MAX_RETRY_MS = 120_000;
|
||||
|
||||
/**
|
||||
* parseRetryAfterMs converts a `Retry-After` header (delta-seconds — the edge emits
|
||||
* seconds, never an HTTP-date) into a millisecond hint clamped to a sane band. An absent
|
||||
* or unparseable value falls back to a default.
|
||||
*/
|
||||
export function parseRetryAfterMs(header: string | null | undefined): number {
|
||||
const secs = header == null ? NaN : Number(header);
|
||||
if (!Number.isFinite(secs) || secs <= 0) return DEFAULT_RETRY_MS;
|
||||
return Math.min(MAX_RETRY_MS, Math.max(MIN_RETRY_MS, Math.round(secs * 1000)));
|
||||
}
|
||||
|
||||
/**
|
||||
* maintenanceRetryMs returns the maintenance `Retry-After` hint in milliseconds when the
|
||||
* thrown transport error is the edge maintenance 503 (carries `X-Scrabble-Maintenance: 1`),
|
||||
* or null for any other error. Header lookup is case-insensitive (Headers.get).
|
||||
*/
|
||||
export function maintenanceRetryMs(e: unknown): number | null {
|
||||
if (!(e instanceof ConnectError)) return null;
|
||||
if (e.metadata?.get('x-scrabble-maintenance') !== '1') return null;
|
||||
return parseRetryAfterMs(e.metadata.get('retry-after'));
|
||||
}
|
||||
+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