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
+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);
}