// Retry policy + error classification for the gateway transport. 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: 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). A rate-limit rejection (ResourceExhausted) is NOT retried and // is NOT a connectivity code: it is a deliberate "slow down", so auto-retrying cannot succeed until the // bucket refills — it only freezes the calling button for the whole backoff and feeds the gateway's ban // tripwire with more rejections. It is surfaced as its own message (error.rate_limited) and never flips // the offline chrome, which the session-less login screen has no session-bearing probe to recover from. 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); case Code.FailedPrecondition: // The Subscribe stream's counterpart of the update_required envelope: the client is too old. return new GatewayError('update_required', 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.export_url', '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 transport 'unavailable' is retried * only for read-only ops, never a mutation; every other code — a rate-limit (a deliberate throttle, * surfaced not spun), a domain rejection, not-found, … — is final. */ export function retryable(code: string, op: string): boolean { 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 and reportOffline may flip the offline * chrome. A rate-limit is deliberately NOT one: it is a genuine server signal, surfaced as its own * "slow down" message, never the offline chrome (which the session-less login screen, whose * reachability probe needs a bearer token, cannot recover from). */ export function isConnectionCode(code: string): boolean { return code === 'unavailable'; } /** 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); }