Stage 17 #2: Connecting indicator + auto-retry, instead of red toasts
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 36s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s

Connectivity failures become state, not a toast on every attempt. A global online
signal (lib/connection.svelte.ts) flips on a transport unavailable / rate_limited and
on the live stream's drop, driving a pure-CSS header spinner + 'Connecting…' in place
of the title and softly disabling the in-game server actions (commit / exchange / pass
/ hint; local board/rack/reset stay live).

- transport: exec auto-retries with capped exponential backoff — every op on a
  rate-limit (rejected before processing, safe), reads only on unavailable (a mutation
  is never blindly re-sent, to avoid double-applying one whose response was lost; its
  button is disabled while offline so the player re-issues on reconnect). A reachability
  watcher (profile.get probe) and any successful traffic clear the signal.
- the old red error.unavailable toast is gone (handleError suppresses connection codes;
  the indicator replaces it). A server-data screen still opens with the spinner and
  fills on reconnect (global indicator + read auto-retry), so navigation is never dead.
- pure retry policy unit-tested (retry.ts); a mock-only window.__conn hook drives a
  Chromium+WebKit e2e (indicator shows offline, the action disables, both clear on
  reconnect). Full suite + build green.
- docs: ARCHITECTURE transport note, FUNCTIONAL (+ _ru), PLAN tracker (incl. #1 — the
  bot already drains all updates, no change).

Also records #1 as investigated/no-change in PLAN. Other server-action buttons (chat
send, profile save, …) still degrade to a safe no-op offline; visual disable is easy to
extend.
This commit is contained in:
Ilia Denisov
2026-06-09 01:48:20 +02:00
parent 844f26bbae
commit ef61b778fc
16 changed files with 334 additions and 18 deletions
+19
View File
@@ -16,6 +16,25 @@ async function openGame(page: Page): Promise<void> {
await expect(page.locator('.pane')).toHaveCount(1);
}
test('offline shows the Connecting indicator and softly disables server actions (Stage 17)', async ({ page }) => {
await openGame(page);
// The exchange/draw tab is a server action; on my turn with tiles in the bag it is live.
const draw = page.locator('.tab').first();
await expect(draw).toBeEnabled();
await expect(page.getByText('Connecting…')).toHaveCount(0);
// Drop the connection (mock-only hook): the header swaps the title for the spinner +
// "Connecting…", and the server action goes inert.
await page.evaluate(() => (window as unknown as { __conn: { offline(): void } }).__conn.offline());
await expect(page.getByText('Connecting…')).toBeVisible();
await expect(draw).toBeDisabled();
// Reconnect: the indicator clears and the action is live again.
await page.evaluate(() => (window as unknown as { __conn: { online(): void } }).__conn.online());
await expect(page.getByText('Connecting…')).toHaveCount(0);
await expect(draw).toBeEnabled();
});
test('placing a tile and confirming via ✅ commits the move', async ({ page }) => {
await openGame(page);
await page.locator('.rack .tile').first().click();
+18 -1
View File
@@ -2,6 +2,9 @@
import type { Snippet } from 'svelte';
import { navigate } from '../lib/router.svelte';
import { insideTelegram } from '../lib/telegram';
import { connection } from '../lib/connection.svelte';
import { t } from '../lib/i18n/index.svelte';
import Spinner from './Spinner.svelte';
let { title, back, menu, grow = false }: { title: string; back?: string; menu?: Snippet; grow?: boolean } =
$props();
@@ -20,7 +23,11 @@
{:else}
<span class="spacer"></span>
{/if}
<h1>{title}</h1>
{#if connection.online}
<h1>{title}</h1>
{:else}
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
{/if}
<div class="end">{#if menu}{@render menu()}{/if}</div>
</div>
</header>
@@ -57,6 +64,16 @@
overflow: hidden;
text-overflow: ellipsis;
}
/* The "Connecting…" indicator replaces the title while offline: a spinner + muted label,
centred like the title so the bar does not shift. */
h1.connecting {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-muted);
font-weight: 500;
}
.icon,
.spacer,
.end {
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
let { size = '1em' }: { size?: string } = $props();
</script>
<span class="sp" style="--sp-size: {size}" aria-hidden="true"></span>
<style>
/* A pure-CSS ring with a single gap (a ~3/4 arc) that rotates — no bundled graphics. It
inherits the surrounding text colour via currentColor, so it works in any header/theme. */
.sp {
display: inline-block;
width: var(--sp-size, 1em);
height: var(--sp-size, 1em);
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
opacity: 0.85;
vertical-align: -0.15em;
animation: sp-spin 0.7s linear infinite;
}
@keyframes sp-spin {
to {
transform: rotate(360deg);
}
}
/* Reduced motion: slow the rotation rather than freeze it (a still ring reads as broken). */
@media (prefers-reduced-motion: reduce) {
.sp {
animation-duration: 1.8s;
}
}
</style>
+5 -4
View File
@@ -10,6 +10,7 @@
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
import { app, handleError, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { GatewayError } from '../lib/client';
import { t } from '../lib/i18n/index.svelte';
import type { Direction, EvalResult, MoveRecord, StateView, Tile } from '../lib/model';
@@ -720,7 +721,7 @@
/>
</div>
{#if !gameOver && placement.pending.length > 0}
<button class="make" onclick={commit} disabled={busy || !isMyTurn || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
{/if}
</div>
{:else}
@@ -730,14 +731,14 @@
{#snippet tabbar()}
{#if view}
<TabBar>
<button class="tab" disabled={busy || !isMyTurn || bagEmpty} onclick={openExchange}>
<button class="tab" disabled={busy || !isMyTurn || !connection.online || bagEmpty} onclick={openExchange}>
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
</button>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doPass}>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || !connection.online} onhold={doPass}>
{#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet}
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet}
</HoldConfirm>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || (view?.hintsRemaining ?? 0) <= 0} onhold={doHint}>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || !connection.online || (view?.hintsRemaining ?? 0) <= 0} onhold={doHint}>
{#snippet trigger()}
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
+11 -3
View File
@@ -23,6 +23,8 @@ import {
} from './telegram';
import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import { reportOffline, reportOnline, resetConnection } from './connection.svelte';
import { isConnectionCode } from './retry';
import { clearGameCache } from './gamecache';
import { clearLobby } from './lobbycache';
import type { BoardLabelMode } from './boardlabels';
@@ -121,6 +123,9 @@ export function handleError(err: unknown): void {
void logout();
return;
}
// A connectivity failure is shown by the "Connecting…" header indicator (and auto-retried),
// not a red toast on every attempt.
if (isConnectionCode(err.code)) return;
showToast(t(errorKey(err.code)), 'error');
return;
}
@@ -132,6 +137,7 @@ function openStream(): void {
streamAlive = true;
unsubscribeStream = gateway.subscribe(
(e) => {
reportOnline(); // a delivered event proves the gateway is reachable
app.lastEvent = e;
if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) {
// While the player is on that game's chat screen, neither toast nor bump the unread.
@@ -155,9 +161,10 @@ function openStream(): void {
},
() => {
streamAlive = false;
// A background suspend drops the single-shot stream. Keep the banner hidden while
// backgrounded or just-resumed (bannerSuppressed); always schedule a retry.
if (!bannerSuppressed()) showToast(t('error.unavailable'), 'error');
// A background suspend drops the single-shot stream. Keep the indicator hidden while
// backgrounded or just-resumed (bannerSuppressed); otherwise show "Connecting…" (the
// reachability watcher and this scheduled retry recover it). Always schedule a retry.
if (!bannerSuppressed()) reportOffline();
scheduleReconnect();
},
);
@@ -399,6 +406,7 @@ export async function loginEmail(email: string, code: string): Promise<void> {
export async function logout(): Promise<void> {
closeStream();
resetConnection();
clearGameCache();
clearLobby();
gateway.setToken(null);
+59
View File
@@ -0,0 +1,59 @@
// Global connectivity signal (Stage 17). `online` is false while the app is actively failing to
// reach the gateway — a unary call retrying after a transport/rate-limit failure, or the live
// stream dropped. The transport and the live-stream owner report transitions; the UI reads
// `connection.online` to show the "Connecting…" indicator and to softly disable proactive
// actions. In mock mode nothing ever reports trouble, so it simply stays online.
//
// Recovery is guaranteed by a reachability watcher: while offline it periodically fires a
// registered probe (a lightweight read) until one succeeds, so the indicator clears even when no
// other traffic is in flight.
import { backoffMs } from './retry';
let online = $state(true);
let watchTimer: ReturnType<typeof setTimeout> | null = null;
let probe: (() => Promise<void>) | null = null;
export const connection = {
/** online is true when the app believes it can reach the gateway. */
get online(): boolean {
return online;
},
};
/** registerProbe installs the reachability probe the watcher fires while offline. The transport
* wires a cheap authenticated read; it should reject when there is no session. */
export function registerProbe(fn: () => Promise<void>): void {
probe = fn;
}
/** reportOnline marks the gateway reachable and stops the watcher. */
export function reportOnline(): void {
online = true;
if (watchTimer) {
clearTimeout(watchTimer);
watchTimer = null;
}
}
/** reportOffline marks the gateway unreachable and starts the reachability watcher (once). */
export function reportOffline(): void {
online = false;
if (!watchTimer && probe) scheduleProbe(1);
}
/** resetConnection restores the online state and stops the watcher (e.g. on logout). */
export function resetConnection(): void {
reportOnline();
}
function scheduleProbe(attempt: number): void {
watchTimer = setTimeout(
() => {
watchTimer = null;
if (online || !probe) return;
probe().then(reportOnline, () => scheduleProbe(Math.min(attempt + 1, 6)));
},
backoffMs(attempt),
);
}
+10
View File
@@ -5,9 +5,19 @@
import type { GatewayClient } from './client';
import { MockGateway } from './mock/client';
import { createTransport } from './transport';
import { reportOffline, reportOnline } from './connection.svelte';
const isMock = import.meta.env.MODE === 'mock';
export const gateway: GatewayClient = isMock
? new MockGateway()
: createTransport(import.meta.env.VITE_GATEWAY_URL ?? '');
// Mock-mode test hook (tree-shaken from a production build, where MODE !== 'mock'): the mock
// transport never exercises the connectivity indicator, so the Playwright e2e drives it directly.
if (isMock && typeof window !== 'undefined') {
(window as unknown as { __conn?: { offline(): void; online(): void } }).__conn = {
offline: reportOffline,
online: reportOnline,
};
}
+1
View File
@@ -4,6 +4,7 @@
export const en = {
'app.title': 'Scrabble',
'connection.connecting': 'Connecting…',
'common.back': 'Back',
'common.cancel': 'Cancel',
+1
View File
@@ -5,6 +5,7 @@ import type { MessageKey } from './en';
export const ru: Record<MessageKey, string> = {
'app.title': 'Scrabble',
'connection.connecting': 'Подключение…',
'common.back': 'Назад',
'common.cancel': 'Отмена',
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { backoffMs, isConnectionCode, retryable } from './retry';
describe('retryable', () => {
it('retries any op on a rate-limit rejection (it never reached the backend)', () => {
expect(retryable('rate_limited', 'game.submit_play')).toBe(true);
expect(retryable('rate_limited', 'games.list')).toBe(true);
expect(retryable('rate_limited', 'chat.post')).toBe(true);
});
it('retries only read-only ops on a transport unavailable (a mutation could double-apply)', () => {
expect(retryable('unavailable', 'games.list')).toBe(true);
expect(retryable('unavailable', 'game.state')).toBe(true);
expect(retryable('unavailable', 'draft.get')).toBe(true);
// mutations are not auto-resent on a dropped connection
expect(retryable('unavailable', 'game.submit_play')).toBe(false);
expect(retryable('unavailable', 'chat.post')).toBe(false);
expect(retryable('unavailable', 'game.hide')).toBe(false);
});
it('never retries a domain rejection or an unknown code', () => {
expect(retryable('not_your_turn', 'game.submit_play')).toBe(false);
expect(retryable('illegal_play', 'game.submit_play')).toBe(false);
expect(retryable('not_found', 'game.state')).toBe(false);
expect(retryable('internal', 'games.list')).toBe(false);
});
});
describe('isConnectionCode', () => {
it('flags the transport/rate-limit codes the indicator covers', () => {
expect(isConnectionCode('unavailable')).toBe(true);
expect(isConnectionCode('rate_limited')).toBe(true);
expect(isConnectionCode('not_your_turn')).toBe(false);
expect(isConnectionCode('internal')).toBe(false);
});
});
describe('backoffMs', () => {
it('grows exponentially and caps at 8s (+ jitter under 250ms)', () => {
// attempt 1 ~ 500, 2 ~ 1000, 3 ~ 2000, 4 ~ 4000, 5 ~ 8000, then capped.
expect(backoffMs(1)).toBeGreaterThanOrEqual(500);
expect(backoffMs(1)).toBeLessThan(750);
expect(backoffMs(3)).toBeGreaterThanOrEqual(2000);
expect(backoffMs(3)).toBeLessThan(2250);
for (const n of [5, 6, 10, 20]) {
expect(backoffMs(n)).toBeGreaterThanOrEqual(8000);
expect(backoffMs(n)).toBeLessThan(8250);
}
});
});
+53
View File
@@ -0,0 +1,53 @@
// Retry policy for the gateway transport (Stage 17). When a unary call fails at the transport
// level the app retries it with capped exponential backoff while showing the "Connecting…"
// indicator, instead of flashing a red toast each time.
//
// Idempotency: a rate-limit rejection (ResourceExhausted) never reached the backend, so any op is
// safe to retry. A transport 'unavailable' is ambiguous for a mutation (its response could have
// been lost after the backend applied it), so only **read-only** ops are auto-retried on
// 'unavailable'; a mutation is surfaced instead (its button is disabled while offline and
// re-enables on reconnect, so the player re-issues it deliberately).
/** READ_OPS is the set of side-effect-free message types (safe to auto-retry on any failure). */
export const READ_OPS: ReadonlySet<string> = new Set([
'profile.get',
'games.list',
'game.state',
'game.history',
'game.gcg',
'game.evaluate',
'game.check_word',
'stats.get',
'lobby.poll',
'chat.list',
'draft.get',
'friends.list',
'friends.incoming',
'friends.outgoing',
'blocks.list',
'invitation.list',
]);
/**
* retryable reports whether a failed op should be auto-retried. A rate-limit rejection is always
* safe (the gateway rejected it before processing); a transport 'unavailable' is retried only for
* read-only ops, never a mutation; every other code (a domain rejection, not-found, …) is final.
*/
export function retryable(code: string, op: string): boolean {
if (code === 'rate_limited') return true;
if (code === 'unavailable') return READ_OPS.has(op);
return false;
}
/** isConnectionCode reports whether a code is a transport/connectivity failure the Connecting
* indicator covers (so the UI suppresses its red toast). */
export function isConnectionCode(code: string): boolean {
return code === 'unavailable' || code === 'rate_limited';
}
/** backoffMs is the delay before retry attempt n (1-based): capped exponential growth plus a
* little jitter, so a fleet of clients does not retry in lockstep after an outage. */
export function backoffMs(attempt: number): number {
const base = Math.min(8000, 500 * 2 ** Math.max(0, attempt - 1));
return base + Math.floor(Math.random() * 250);
}
+32 -7
View File
@@ -10,6 +10,11 @@ import { createConnectTransport } from '@connectrpc/connect-web';
import { Gateway } from '../gen/edge/v1/edge_pb';
import { GatewayError, type GatewayClient } from './client';
import * as codec from './codec';
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
import { backoffMs, isConnectionCode, retryable } from './retry';
const MAX_RETRIES = 6;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
function toGatewayError(e: unknown): GatewayError {
if (e instanceof ConnectError) {
@@ -38,15 +43,35 @@ export function createTransport(baseUrl: string): GatewayClient {
const headers = (): Record<string, string> | undefined =>
token ? { authorization: `Bearer ${token}` } : undefined;
// 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 () => {
if (!token) throw new Error('no session');
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() });
});
// 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
// indicator. A successful round-trip marks the gateway reachable; a domain result_code is final.
async function exec(messageType: string, payload: Uint8Array): Promise<Uint8Array> {
let res;
try {
res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers() });
} catch (e) {
throw toGatewayError(e);
for (let attempt = 0; ; attempt++) {
let res;
try {
res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers() });
} catch (e) {
const err = toGatewayError(e);
if (retryable(err.code, messageType) && attempt < MAX_RETRIES) {
reportOffline();
await sleep(backoffMs(attempt + 1));
continue;
}
if (isConnectionCode(err.code)) reportOffline();
throw err;
}
reportOnline();
if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
return res.payload;
}
if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
return res.payload;
}
return {