// 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 // 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). 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', '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); }