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