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
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:
@@ -1437,6 +1437,28 @@ 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). Other server-action buttons (chat send, profile save, …) are not yet visually
|
||||
disabled but degrade to a safe no-op (the suppressed toast + indicator) — easy to extend.
|
||||
|
||||
## Deferred TODOs (cross-stage)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
@@ -48,7 +48,12 @@ Mini App** авторизует по подписанным `initData` плат
|
||||
Уведомление «ваш ход» называет соперника и пересказывает его последний ход — слово и
|
||||
текущий счёт для результативного хода либо что он поменял фишки или пропустил, — а по
|
||||
завершении партии приходит уведомление «конец партии» с твоим результатом и финальным
|
||||
счётом (счёт читается, твой первым).
|
||||
счётом (счёт читается, твой первым). Если связь пропадает или сервер ограничивает частоту
|
||||
запросов, приложение не донимает ошибками: в шапке тихо крутится спиннер **«Подключение…»**,
|
||||
пока идёт переподключение, действия, отправляющие данные на сервер, приостанавливаются до
|
||||
восстановления связи (экран с серверными данными всё равно открывается — со спиннером — и
|
||||
подгружается при реконнекте), а незавершённые чтения возобновляются сами — интерфейс остаётся
|
||||
рабочим вместо красного баннера каждый раз.
|
||||
|
||||
### Аккаунты, привязка и слияние *(Stage 1 / 11)*
|
||||
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export const en = {
|
||||
'app.title': 'Scrabble',
|
||||
'connection.connecting': 'Connecting…',
|
||||
|
||||
'common.back': 'Back',
|
||||
'common.cancel': 'Cancel',
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MessageKey } from './en';
|
||||
|
||||
export const ru: Record<MessageKey, string> = {
|
||||
'app.title': 'Scrabble',
|
||||
'connection.connecting': 'Подключение…',
|
||||
|
||||
'common.back': 'Назад',
|
||||
'common.cancel': 'Отмена',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user