diff --git a/ui/e2e/maintenance.spec.ts b/ui/e2e/maintenance.spec.ts new file mode 100644 index 0000000..4d913dd --- /dev/null +++ b/ui/e2e/maintenance.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from './fixtures'; + +// The maintenance overlay is raised in prod by the edge 503 marker (X-Scrabble-Maintenance); +// the mock transport never produces one, so — like the offline indicator — the e2e drives it +// through the window.__maint hook (gateway.ts, mock-only). It is app-global (mounted outside +// the route blocks in App.svelte), so it shows without a session. +test('maintenance overlay covers the app and lifts on recovery', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('alertdialog')).toHaveCount(0); + + // A planned deploy window begins: a non-dismissable dimmed overlay covers the app. + await page.evaluate(() => (window as unknown as { __maint: { on(): void } }).__maint.on()); + const overlay = page.getByRole('alertdialog'); + await expect(overlay).toBeVisible(); + // The retry button is present (EN "Try again" / RU "Повторить" depending on locale). + await expect(overlay.getByRole('button', { name: /again|Повторить/i })).toBeVisible(); + + // The window ends — in prod the store's poll lifts it on the first successful read; the mock + // has no probe, so it clears explicitly. The overlay disappears with no page reload. + await page.evaluate(() => (window as unknown as { __maint: { off(): void } }).__maint.off()); + await expect(page.getByRole('alertdialog')).toHaveCount(0); +}); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index a1b8824..ad3f36d 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -9,6 +9,7 @@ import StaleInviteModal from './components/StaleInviteModal.svelte'; import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte'; import Coachmark from './components/Coachmark.svelte'; + import MaintenanceOverlay from './components/MaintenanceOverlay.svelte'; import DebugPanel from './components/DebugPanel.svelte'; import Login from './screens/Login.svelte'; import Lobby from './screens/Lobby.svelte'; @@ -128,6 +129,7 @@ + {#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError} diff --git a/ui/src/components/MaintenanceOverlay.svelte b/ui/src/components/MaintenanceOverlay.svelte new file mode 100644 index 0000000..e002597 --- /dev/null +++ b/ui/src/components/MaintenanceOverlay.svelte @@ -0,0 +1,83 @@ + + +{#if maintenance.active} +
+
+ +

{t('maintenance.title')}

+

{t('maintenance.body')}

+ +
+
+{/if} + + diff --git a/ui/src/lib/gateway.ts b/ui/src/lib/gateway.ts index 6e2d805..7cbe567 100644 --- a/ui/src/lib/gateway.ts +++ b/ui/src/lib/gateway.ts @@ -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). ( diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 1739be6..e19daca 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -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.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 4ee0347..81e6966 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -8,6 +8,9 @@ export const ru: Record = { 'app.brand': 'Эрудит', 'dict.loading': 'Загрузка…', 'connection.connecting': 'Подключение…', + 'maintenance.title': 'Технические работы', + 'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.', + 'maintenance.retry': 'Повторить', 'blocked.title': 'Учётная запись заблокирована', 'blocked.permanent': 'Ваша учётная запись заблокирована.', diff --git a/ui/src/lib/maintenance.svelte.ts b/ui/src/lib/maintenance.svelte.ts new file mode 100644 index 0000000..65133e8 --- /dev/null +++ b/ui/src/lib/maintenance.svelte.ts @@ -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 | null = null; +let attempt = 0; +let probing = false; +let probe: (() => Promise) | 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 { + 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(); + }, + ); +} diff --git a/ui/src/lib/maintenance.test.ts b/ui/src/lib/maintenance.test.ts new file mode 100644 index 0000000..24f0092 --- /dev/null +++ b/ui/src/lib/maintenance.test.ts @@ -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): 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); + }); +}); diff --git a/ui/src/lib/maintenance.ts b/ui/src/lib/maintenance.ts new file mode 100644 index 0000000..63dbcf4 --- /dev/null +++ b/ui/src/lib/maintenance.ts @@ -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')); +} diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 1ec3551..2230466 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -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 => { 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();