Merge pull request 'Stage 17 #2: Connecting indicator + auto-retry (no more red toasts)' (#29) from feature/connecting-indicator into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 36s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 58s

This commit was merged in pull request #29.
This commit is contained in:
2026-06-09 05:46:43 +00:00
25 changed files with 451 additions and 66 deletions
+24
View File
@@ -1437,6 +1437,30 @@ provided cert) at the contour caddy; prod VPN; rollback.
connector. The backend resolves the mover's display name (the score line and result are
built per recipient). Covered by notify round-trip, emit, connector-render (en/ru) and
routing tests.
- **No-op drain of all bot updates (#1, investigated — no change):** confirmed the Telegram bot
already long-polls and the library advances the offset for every delivered update (the default
handler no-ops anything but `/start`), so the queue never piles up. Telegram withholds only
`message_reaction` / `message_reaction_count` / `chat_member` by default, and — being
unrequested — those never queue either. Owner chose to leave `allowed_updates` at the default
(zero risk) rather than hand-maintain a full whitelist (a wrong/stale type would break
`getUpdates` entirely); a specific type will be requested when a concrete handler needs it.
- **Reconnect UX — "Connecting…" + soft-disable (#2, shipped):** connectivity failures became
**state, not toasts**. 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). The
transport (`exec`) **auto-retries with capped backoff** — every op on a rate-limit, **reads
only** on `unavailable` (mutations are not blindly re-sent; their buttons are disabled while
offline, so the player re-issues on reconnect — the idempotency caveat the owner accepted). A
reachability **watcher** (`profile.get` probe) and any successful traffic clear the signal; the
old red `error.unavailable` toast is gone (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 policy unit-tested (`retry.ts`); a mock-only `window.__conn`
hook drives a Chromium+WebKit e2e (indicator appears offline, the action disables, both clear
on reconnect). The visual soft-disable spans the server-action buttons across the app: the
game bar (commit/exchange/pass/hint/resign), chat send + nudge, profile save / link / merge,
friends (request/respond/unfriend/block/code), New Game (auto-match + invite) and the lobby
hide ❌; purely local controls (board/rack/reset, menu, navigation, settings) stay live.
## Deferred TODOs (cross-stage)
+14
View File
@@ -23,6 +23,11 @@ import (
// is unbounded; auto-provisioned platform names bypass this editor validation).
const maxDisplayName = 32
// maxDisplayNameSpecials caps the total special characters (the "." / "_" separators —
// every name rune that is neither a letter nor a space) an editable display name may
// carry, so a still-well-formed name cannot be made of mostly punctuation (Stage 17).
const maxDisplayNameSpecials = 5
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
const maxAwayWindow = 12 * time.Hour
@@ -110,6 +115,15 @@ func ValidateDisplayName(raw string) (string, error) {
if !displayNameRe.MatchString(name) {
return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile)
}
specials := 0
for _, r := range name {
if r != ' ' && !unicode.IsLetter(r) {
specials++
}
}
if specials > maxDisplayNameSpecials {
return "", fmt.Errorf("%w: display name has more than %d special characters", ErrInvalidProfile, maxDisplayNameSpecials)
}
return name, nil
}
@@ -27,6 +27,9 @@ func TestValidateDisplayName(t *testing.T) {
"digit rejected": {"Name2", "", false},
"blank": {" ", "", false},
"too long": {strings.Repeat("a", 33), "", false},
"five specials ok": {"a.a.a.a.a.a", "a.a.a.a.a.a", true}, // 5 dots
"six specials": {"a.a.a.a.a.a.a", "", false}, // 6 dots
"initials spaces ok": {"J. R. R. Tolkien", "J. R. R. Tolkien", true}, // 3 dots; spaces don't count
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
+9 -1
View File
@@ -89,7 +89,15 @@ dropped). Horizontal scaling is explicit future work.
auth operations are unauthenticated and return the minted token. A unary
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
200); only edge failures (rate limit, missing session, unknown type, internal)
surface as Connect error codes.
surface as Connect error codes. The client (Stage 17) treats a connectivity edge failure as
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive
actions, and the transport **auto-retries with capped exponential backoff** — every op on a
rate-limit (the gateway rejected it before processing, so it is safe), but only **read-only**
ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying one whose
response was lost — its button is disabled while offline and the player re-issues it on
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal.
- **Alphabet on the wire (Stage 13)**: live play exchanges **alphabet indices**, not
concrete letters. The rack (`StateView.rack`), the `SubmitPlay`/`Evaluate` tiles, the
`Exchange` tiles and the `CheckWord` word are `ubyte` indices into the variant's alphabet
+8 -2
View File
@@ -46,7 +46,12 @@ request) arrive as a **Telegram notification** instead — unless the player kee
notifications in the app only (a profile setting, **on by default**). The "your turn"
notification names the opponent and recaps their last move — the word and the running score
for a scoring play, or that they swapped or passed — and a finished game sends a "game over"
notification with your result and the final score (scores read with yours first).
notification with your result and the final score (scores read with yours first). If the
connection drops or the server is rate-limiting, the app does not nag with errors: the header
shows a quiet **"Connecting…"** spinner while it reconnects, actions that send to the server
pause until it is back (a server-data screen still opens, with the spinner, and fills in on
reconnect), and pending reads resume on their own — the interface stays usable instead of
flashing a red banner each time.
### Accounts, linking & merge *(Stage 1 / 11)*
First platform contact auto-provisions a durable account. From the profile a player
@@ -136,7 +141,8 @@ new chat message raises an **unread badge** on the game's menu until the chat is
### Profile & settings *(Stage 4 / 8)*
Edit the display name (letters joined by a single space / "." / "_" separator, with an
optional trailing ".", up to 32 characters), the timezone (chosen as a UTC offset), the
optional trailing ".", up to 32 characters and at most 5 special characters — the "." / "_"
punctuation, spaces aside), the timezone (chosen as a UTC offset), the
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
block toggles. The profile form is edited inline (no separate edit mode). Linking
an email or Telegram and merging accounts are covered under "Accounts, linking &
+8 -2
View File
@@ -48,7 +48,12 @@ Mini App** авторизует по подписанным `initData` плат
Уведомление «ваш ход» называет соперника и пересказывает его последний ход — слово и
текущий счёт для результативного хода либо что он поменял фишки или пропустил, — а по
завершении партии приходит уведомление «конец партии» с твоим результатом и финальным
счётом (счёт читается, твой первым).
счётом (счёт читается, твой первым). Если связь пропадает или сервер ограничивает частоту
запросов, приложение не донимает ошибками: в шапке тихо крутится спиннер **«Подключение…»**,
пока идёт переподключение, действия, отправляющие данные на сервер, приостанавливаются до
восстановления связи (экран с серверными данными всё равно открывается — со спиннером — и
подгружается при реконнекте), а незавершённые чтения возобновляются сами — интерфейс остаётся
рабочим вместо красного баннера каждый раз.
### Аккаунты, привязка и слияние *(Stage 1 / 11)*
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
@@ -141,7 +146,8 @@ push доставляется через платформу.
### Профиль и настройки *(Stage 4 / 8)*
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
«_», с необязательной завершающей «.», до 32 символов), таймзоны (выбор смещения от
«_», с необязательной завершающей «.», до 32 символов и не более 5 спецсимволов —
пунктуации «.» / «_», пробелы не в счёт), таймзоны (выбор смещения от
UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с
переходом через полночь) и переключателей блокировок. Форма профиля редактируется
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
+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>
+3 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import type { ChatMessage } from '../lib/model';
import { t } from '../lib/i18n/index.svelte';
import { connection } from '../lib/connection.svelte';
let {
messages,
@@ -55,11 +56,11 @@
bind:value={text}
onkeydown={(e) => e.key === 'Enter' && send()}
/>
<button class="iconbtn" onclick={send} disabled={busy} aria-label={t('chat.send')}>⬆️</button>
<button class="iconbtn" onclick={send} disabled={busy || !connection.online} aria-label={t('chat.send')}>⬆️</button>
{:else}
<!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. -->
<span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span>
<button class="iconbtn" onclick={onnudge} disabled={busy || nudgeOnCooldown} aria-label={t('chat.nudgeAction')}>🛎️</button>
<button class="iconbtn" onclick={onnudge} disabled={busy || nudgeOnCooldown || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
{/if}
</div>
</div>
+6 -5
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>
@@ -793,7 +794,7 @@
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
<div class="confirm-row">
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
<button class="danger" onclick={doResign}>{t('game.dropGame')}</button>
<button class="danger" onclick={doResign} disabled={!connection.online}>{t('game.dropGame')}</button>
</div>
</Modal>
{/if}
+17 -12
View File
@@ -23,6 +23,8 @@ import {
} from './telegram';
import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
import { isConnectionCode } from './retry';
import { clearGameCache } from './gamecache';
import { clearLobby } from './lobbycache';
import type { BoardLabelMode } from './boardlabels';
@@ -113,18 +115,18 @@ export function clearChatUnread(gameId: string): void {
if (app.chatUnread[gameId]) app.chatUnread = { ...app.chatUnread, [gameId]: 0 };
}
/** handleError maps a GatewayError to a toast; an invalid session logs out. */
/** handleError maps a GatewayError to a toast; an invalid session logs out. A connectivity
* failure — or anything raised while the app is mid-reconnect — is shown by the "Connecting…"
* header indicator (and auto-retried), never a red toast. */
export function handleError(err: unknown): void {
telegramHaptic('error');
if (err instanceof GatewayError) {
if (err.code === 'session_invalid' || err.code === 'unauthenticated') {
void logout();
return;
}
showToast(t(errorKey(err.code)), 'error');
const code = err instanceof GatewayError ? err.code : '';
if (code === 'session_invalid' || code === 'unauthenticated') {
void logout();
return;
}
showToast(t('error.generic'), 'error');
if (isConnectionCode(code) || !connection.online) return;
telegramHaptic('error');
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
}
function openStream(): void {
@@ -132,6 +134,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 +158,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 +403,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': 'Отмена',
+3
View File
@@ -19,6 +19,9 @@ describe('validDisplayName', () => {
['Name2', false],
['', false],
['a'.repeat(33), false],
['a.a.a.a.a.a', true], // 5 dots — at the special-char limit
['a.a.a.a.a.a.a', false], // 6 dots — over the limit
['J. R. R. Tolkien', true], // 3 dots; spaces are not special
])('%s -> %s', (name, ok) => {
expect(validDisplayName(name)).toBe(ok);
});
+8 -1
View File
@@ -5,6 +5,10 @@
/** maxDisplayName caps the editable display name in runes. */
export const maxDisplayName = 32;
/** maxDisplayNameSpecials caps the total special characters (the "." / "_" separators — every
* rune that is neither a letter nor a space) a display name may carry. Mirrors the Go rule. */
export const maxDisplayNameSpecials = 5;
/** maxAwayMinutes bounds the daily away window's length (12 h). */
export const maxAwayMinutes = 12 * 60;
@@ -17,7 +21,10 @@ const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$/u;
/** displayNameError returns true when the trimmed name is a valid display name. */
export function validDisplayName(raw: string): boolean {
const name = raw.trim();
return name.length > 0 && [...name].length <= maxDisplayName && displayNameRe.test(name);
const chars = [...name];
if (name.length === 0 || chars.length > maxDisplayName || !displayNameRe.test(name)) return false;
const specials = chars.filter((c) => c !== ' ' && !/\p{L}/u.test(c)).length;
return specials <= maxDisplayNameSpecials;
}
// A pragmatic email check (the backend re-validates with net/mail). Rejects spaces
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { Code, ConnectError } from '@connectrpc/connect';
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
describe('toGatewayError', () => {
it('collapses every transport-level failure to a connection code (so it is retried + suppressed)', () => {
for (const c of [Code.Unavailable, Code.DeadlineExceeded, Code.Canceled, Code.Aborted, Code.Unknown]) {
const e = toGatewayError(new ConnectError('x', c));
expect(e.code).toBe('unavailable');
expect(isConnectionCode(e.code)).toBe(true);
}
});
it('treats a raw (non-Connect) network error as a connection failure', () => {
expect(toGatewayError(new TypeError('Failed to fetch')).code).toBe('unavailable');
});
it('preserves rate-limit, an invalid session, and a genuine server-internal error', () => {
expect(toGatewayError(new ConnectError('x', Code.ResourceExhausted)).code).toBe('rate_limited');
expect(toGatewayError(new ConnectError('x', Code.Unauthenticated)).code).toBe('session_invalid');
expect(toGatewayError(new ConnectError('x', Code.Internal)).code).toBe('internal');
expect(isConnectionCode('internal')).toBe(false);
});
});
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);
}
});
});
+85
View File
@@ -0,0 +1,85 @@
// Retry policy + error classification 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).
import { Code, ConnectError } from '@connectrpc/connect';
import { GatewayError } from './client';
/**
* toGatewayError normalises a thrown Connect/transport error to a GatewayError with a stable code.
* Connection-level failures — the server is unreachable, the request timed out, was reset or
* cancelled, or a raw network error — all collapse to **'unavailable'**, so they are handled as
* connectivity (the indicator + retry), never as a red error toast. A genuine server-side
* 'internal' or a domain code is preserved.
*/
export function toGatewayError(e: unknown): GatewayError {
if (e instanceof ConnectError) {
switch (e.code) {
case Code.Unauthenticated:
return new GatewayError('session_invalid', e.message);
case Code.ResourceExhausted:
return new GatewayError('rate_limited', e.message);
case Code.Unavailable:
case Code.DeadlineExceeded:
case Code.Canceled:
case Code.Aborted:
case Code.Unknown:
return new GatewayError('unavailable', e.message);
case Code.NotFound:
return new GatewayError('not_found', e.message);
default:
return new GatewayError('internal', e.message);
}
}
return new GatewayError('unavailable', String(e));
}
/** 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 -25
View File
@@ -5,29 +5,16 @@
// a thrown GatewayError. In dev the Vite proxy forwards the RPC path to the h2c
// gateway; in a packaged app VITE_GATEWAY_URL points at the real origin.
import { Code, ConnectError, createClient } from '@connectrpc/connect';
import { createClient } from '@connectrpc/connect';
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, toGatewayError } from './retry';
function toGatewayError(e: unknown): GatewayError {
if (e instanceof ConnectError) {
switch (e.code) {
case Code.Unauthenticated:
return new GatewayError('session_invalid', e.message);
case Code.ResourceExhausted:
return new GatewayError('rate_limited', e.message);
case Code.Unavailable:
return new GatewayError('unavailable', e.message);
case Code.NotFound:
return new GatewayError('not_found', e.message);
default:
return new GatewayError('internal', e.message);
}
}
return new GatewayError('unavailable', String(e));
}
const MAX_RETRIES = 6;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
export function createTransport(baseUrl: string): GatewayClient {
const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : '');
@@ -38,15 +25,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 {
+8 -7
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { t } from '../lib/i18n/index.svelte';
import { friendCodeParam, shareLink } from '../lib/deeplink';
@@ -95,7 +96,7 @@
inputmode="numeric"
maxlength="6"
/>
<button class="btn" onclick={redeem}>{t('friends.redeem')}</button>
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
</div>
{#if code}
{@const tg = shareLink(friendCodeParam(code.code))}
@@ -112,7 +113,7 @@
{/if}
</div>
{:else}
<button class="link" onclick={getCode}>{t('friends.getCode')}</button>
<button class="link" onclick={getCode} disabled={!connection.online}>{t('friends.getCode')}</button>
{/if}
</section>
@@ -123,8 +124,8 @@
<div class="item">
<span class="who">{r.displayName}</span>
<span class="acts">
<button class="btn" onclick={() => respond(r.accountId, true)}>{t('friends.accept')}</button>
<button class="ghost" onclick={() => respond(r.accountId, false)}>{t('friends.decline')}</button>
<button class="btn" onclick={() => respond(r.accountId, true)} disabled={!connection.online}>{t('friends.accept')}</button>
<button class="ghost" onclick={() => respond(r.accountId, false)} disabled={!connection.online}>{t('friends.decline')}</button>
</span>
</div>
{/each}
@@ -138,8 +139,8 @@
<div class="item">
<span class="who">{f.displayName}</span>
<span class="acts">
<button class="ghost" onclick={() => remove(f.accountId)}>{t('friends.unfriend')}</button>
<button class="ghost danger" onclick={() => blockUser(f.accountId)}>{t('friends.block')}</button>
<button class="ghost" onclick={() => remove(f.accountId)} disabled={!connection.online}>{t('friends.unfriend')}</button>
<button class="ghost danger" onclick={() => blockUser(f.accountId)} disabled={!connection.online}>{t('friends.block')}</button>
</span>
</div>
{/each}
@@ -154,7 +155,7 @@
{#each blocked as b (b.accountId)}
<div class="item">
<span class="who">{b.displayName}</span>
<button class="ghost" onclick={() => unblock(b.accountId)}>{t('friends.unblock')}</button>
<button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button>
</div>
{/each}
</section>
+2 -1
View File
@@ -4,6 +4,7 @@
import Menu from '../components/Menu.svelte';
import TabBar from '../components/TabBar.svelte';
import { app, handleError, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
@@ -191,7 +192,7 @@
{#each group.list as g (g.id)}
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
{#if group.finished}
<button class="del" onclick={() => hide(g.id)} aria-label={t('lobby.hideGame')}></button>
<button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button>
{/if}
<div class="row">
<button
+3 -2
View File
@@ -3,6 +3,7 @@
import Screen from '../components/Screen.svelte';
import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { navigate } from '../lib/router.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import type { AccountRef, Variant } from '../lib/model';
@@ -144,7 +145,7 @@
<p class="subtitle">{t('new.subtitle')}</p>
<div class="variants">
{#each variants as v (v.id)}
<button class="variant" onclick={() => find(v.id)}>
<button class="variant" onclick={() => find(v.id)} disabled={!connection.online}>
<span class="vmain">
<span class="vname">{t(v.label)}</span>
{#if VARIANT_FLAG[v.id]}
@@ -196,7 +197,7 @@
</select>
</label>
</div>
<button class="invite" disabled={selected.length === 0 || !inviteVariant} onclick={sendInvite}>{t('new.invite')}</button>
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
</div>
{/if}
{/if}
+6 -5
View File
@@ -3,6 +3,7 @@
import Modal from '../components/Modal.svelte';
import Screen from '../components/Screen.svelte';
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
import { t } from '../lib/i18n/index.svelte';
@@ -209,7 +210,7 @@
<span>{t('profile.notificationsInAppOnly')}</span>
</label>
<div class="formacts">
<button type="submit" class="btn" disabled={!formValid}>{t('common.save')}</button>
<button type="submit" class="btn" disabled={!formValid || !connection.online}>{t('common.save')}</button>
</div>
</form>
{/if}
@@ -226,7 +227,7 @@
placeholder={t('login.emailPlaceholder')}
type="email"
/>
<button class="ghost" onclick={requestEmail} disabled={!emailOk}>{t('login.sendCode')}</button>
<button class="ghost" onclick={requestEmail} disabled={!emailOk || !connection.online}>{t('login.sendCode')}</button>
</div>
{:else}
<div class="addrow">
@@ -237,11 +238,11 @@
inputmode="numeric"
maxlength="6"
/>
<button class="btn" onclick={confirmEmail}>{t('common.ok')}</button>
<button class="btn" onclick={confirmEmail} disabled={!connection.online}>{t('common.ok')}</button>
</div>
{/if}
{#if telegramLinkable}
<button class="ghost tg" onclick={linkTelegram}>{t('profile.linkTelegram')}</button>
<button class="ghost tg" onclick={linkTelegram} disabled={!connection.online}>{t('profile.linkTelegram')}</button>
{/if}
</section>
@@ -257,7 +258,7 @@
<p class="warn">{t('profile.mergeIrreversible')}</p>
<div class="addrow end">
<button class="ghost" onclick={() => (pendingMerge = null)}>{t('common.cancel')}</button>
<button class="btn" onclick={confirmMerge}>{t('profile.mergeConfirm')}</button>
<button class="btn" onclick={confirmMerge} disabled={!connection.online}>{t('profile.mergeConfirm')}</button>
</div>
</Modal>
{/if}