// 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')); }