Files
scrabble-game/ui/src/lib/retry.ts
T
Ilia Denisov fe5a3d6d3b
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m7s
fix(login): stop a rate-limit from latching a phantom offline on the login screen
A fresh email login that hit the per-IP email rate limit stranded the user in
an unrecoverable "offline" state that survived a full PWA restart, even though
the network was fine.

Root cause: the gateway email class (auth.email.request + auth.email.login,
keyed per IP, 5/10min burst 2) trips on the third event, which in the natural
"request code -> wrong code -> correct code" sequence is the correct-code login.
The gateway returns ResourceExhausted; the client mapped it to 'rate_limited',
which retry.ts classified as retryable + a connection code, so exec() called
reportOffline(). That pushes the net-state machine into connecting, whose
recovery probe is an authenticated profile.get -- but the login screen has no
session, so the probe can never succeed and the machine latches offline. The
transport kill switch (assertOnline) then refuses the very login that would fix
it, and because the IP's email bucket refills only 1 token / 120s, each fresh
attempt after a restart is rate-limited again -> offline again.

Fix (client, narrow -- the trigger):
- A rate-limit is no longer treated as connectivity. retry.ts no longer marks
  'rate_limited' retryable or a connection code, so exec() never reports it
  offline; it surfaces as the existing error.rate_limited "slow down" message.
  Not auto-retrying it also stops the ~20s button freeze and avoids feeding the
  gateway's ban tripwire with 6 extra rejections per attempt.

Fix (server):
- Raise the email-code burst 2 -> 4 so the honest request + a mistyped code +
  the correct one is not throttled mid-login. Defence-in-depth over the
  backend's own per-code guards (5-attempt cap + 15-min TTL + send throttle).

Tests: retry classification units updated to the new semantics; a gateway
regression guard asserts the honest three-event email flow passes under the
default policy. gateway/README.md rate-limit note updated.

The deeper gap (the net-state recovery probe is session-gated, so any real
transport failure on the session-less login/confirm screens still cannot
self-heal) is left as a known residual, deferred by the owner.
2026-07-13 22:48:59 +02:00

95 lines
4.2 KiB
TypeScript

// 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<string> = 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);
}