d67e582c03
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).
45 lines
1.9 KiB
TypeScript
45 lines
1.9 KiB
TypeScript
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);
|
||
});
|
||
});
|