Stage 17 #2: Connecting indicator + auto-retry (no more red toasts) #29

Merged
developer merged 4 commits from feature/connecting-indicator into development 2026-06-09 05:46:43 +00:00
4 changed files with 70 additions and 37 deletions
Showing only changes of commit 84ecc85f51 - Show all commits
+9 -12
View File
@@ -23,7 +23,7 @@ import {
} from './telegram'; } from './telegram';
import { parseStartParam } from './deeplink'; import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; 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 { isConnectionCode } from './retry';
import { clearGameCache } from './gamecache'; import { clearGameCache } from './gamecache';
import { clearLobby } from './lobbycache'; import { clearLobby } from './lobbycache';
@@ -115,21 +115,18 @@ export function clearChatUnread(gameId: string): void {
if (app.chatUnread[gameId]) app.chatUnread = { ...app.chatUnread, [gameId]: 0 }; 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 { export function handleError(err: unknown): void {
telegramHaptic('error'); const code = err instanceof GatewayError ? err.code : '';
if (err instanceof GatewayError) { if (code === 'session_invalid' || code === 'unauthenticated') {
if (err.code === 'session_invalid' || err.code === 'unauthenticated') {
void logout(); void logout();
return; return;
} }
// A connectivity failure is shown by the "Connecting…" header indicator (and auto-retried), if (isConnectionCode(code) || !connection.online) return;
// not a red toast on every attempt. telegramHaptic('error');
if (isConnectionCode(err.code)) return; showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
showToast(t(errorKey(err.code)), 'error');
return;
}
showToast(t('error.generic'), 'error');
} }
function openStream(): void { function openStream(): void {
+23 -1
View File
@@ -1,5 +1,27 @@
import { describe, expect, it } from 'vitest'; 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', () => { describe('retryable', () => {
it('retries any op on a rate-limit rejection (it never reached the backend)', () => { it('retries any op on a rate-limit rejection (it never reached the backend)', () => {
+35 -3
View File
@@ -1,6 +1,6 @@
// Retry policy for the gateway transport (Stage 17). When a unary call fails at the transport // Retry policy + error classification for the gateway transport (Stage 17). When a unary call
// level the app retries it with capped exponential backoff while showing the "Connecting…" // fails at the transport level the app retries it with capped exponential backoff while showing
// indicator, instead of flashing a red toast each time. // 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 // 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 // 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 // 'unavailable'; a mutation is surfaced instead (its button is disabled while offline and
// re-enables on reconnect, so the player re-issues it deliberately). // 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). */ /** 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([ export const READ_OPS: ReadonlySet<string> = new Set([
'profile.get', 'profile.get',
+2 -20
View File
@@ -5,35 +5,17 @@
// a thrown GatewayError. In dev the Vite proxy forwards the RPC path to the h2c // 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. // 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 { createConnectTransport } from '@connectrpc/connect-web';
import { Gateway } from '../gen/edge/v1/edge_pb'; import { Gateway } from '../gen/edge/v1/edge_pb';
import { GatewayError, type GatewayClient } from './client'; import { GatewayError, type GatewayClient } from './client';
import * as codec from './codec'; import * as codec from './codec';
import { registerProbe, reportOffline, reportOnline } from './connection.svelte'; 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 MAX_RETRIES = 6;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms)); const sleep = (ms: number): Promise<void> => 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 { export function createTransport(baseUrl: string): GatewayClient {
const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : ''); const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : '');
const transport = createConnectTransport({ baseUrl: origin, useBinaryFormat: true }); const transport = createConnectTransport({ baseUrl: origin, useBinaryFormat: true });