From 84ecc85f51406a4ce857ab7d165f8d6df1a5ecdf Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 9 Jun 2026 07:42:47 +0200 Subject: [PATCH] Stage 17 #2 fix: connection failures show only the spinner, never a toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dropped/reset/timed-out connection can surface as a Connect code other than Unavailable (Canceled/DeadlineExceeded/Unknown/…) which fell through to the generic 'internal' -> a red 'something went wrong' toast appeared alongside the Connecting spinner. Now toGatewayError (moved to the pure retry.ts, unit-tested) collapses every transport-level code to 'unavailable' so it is retried + flips offline; and handleError suppresses the toast for any connection code AND whenever the app is mid-reconnect (!connection.online), covering the race where a unary error lands before the stream reports the drop. Genuine server-internal / domain errors still toast while online. --- ui/src/lib/app.svelte.ts | 23 ++++++++++------------- ui/src/lib/retry.test.ts | 24 +++++++++++++++++++++++- ui/src/lib/retry.ts | 38 +++++++++++++++++++++++++++++++++++--- ui/src/lib/transport.ts | 22 ++-------------------- 4 files changed, 70 insertions(+), 37 deletions(-) diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index f138d19..793dcc9 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -23,7 +23,7 @@ import { } from './telegram'; import { parseStartParam } from './deeplink'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; -import { reportOffline, reportOnline, resetConnection } from './connection.svelte'; +import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte'; import { isConnectionCode } from './retry'; import { clearGameCache } from './gamecache'; import { clearLobby } from './lobbycache'; @@ -115,21 +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; - } - // 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'); + 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 { diff --git a/ui/src/lib/retry.test.ts b/ui/src/lib/retry.test.ts index 7f41a07..fb13066 100644 --- a/ui/src/lib/retry.test.ts +++ b/ui/src/lib/retry.test.ts @@ -1,5 +1,27 @@ import { describe, expect, it } from 'vitest'; -import { backoffMs, isConnectionCode, retryable } from './retry'; +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)', () => { diff --git a/ui/src/lib/retry.ts b/ui/src/lib/retry.ts index aef1360..ad724f5 100644 --- a/ui/src/lib/retry.ts +++ b/ui/src/lib/retry.ts @@ -1,6 +1,6 @@ -// 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. +// 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 @@ -8,6 +8,38 @@ // '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 = new Set([ 'profile.get', diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 2e881ac..9e8366c 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -5,35 +5,17 @@ // 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 } from './retry'; +import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry'; const MAX_RETRIES = 6; const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); -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)); -} - export function createTransport(baseUrl: string): GatewayClient { const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : ''); const transport = createConnectTransport({ baseUrl: origin, useBinaryFormat: true });