fix(login): stop a rate-limit from latching a phantom offline on the login screen
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
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
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.
This commit is contained in:
@@ -28,10 +28,15 @@ describe('toGatewayError', () => {
|
||||
});
|
||||
|
||||
describe('retryable', () => {
|
||||
it('retries any op on a rate-limit rejection (it never reached the backend)', () => {
|
||||
expect(retryable('rate_limited', 'game.submit_play')).toBe(true);
|
||||
expect(retryable('rate_limited', 'games.list')).toBe(true);
|
||||
expect(retryable('rate_limited', 'chat.post')).toBe(true);
|
||||
it('does not auto-retry a rate-limit rejection (a deliberate throttle, surfaced not spun)', () => {
|
||||
// A rate-limit is the server saying "slow down": auto-retrying cannot succeed until the bucket
|
||||
// refills, only freezes the calling button for the whole backoff and feeds the gateway's ban
|
||||
// tripwire with more rejections. It is surfaced to the caller instead. (Regression guard: the
|
||||
// old retry path also called reportOffline, which on the session-less login screen — where the
|
||||
// reachability probe can never heal — latched a stuck phantom offline.)
|
||||
expect(retryable('rate_limited', 'game.submit_play')).toBe(false);
|
||||
expect(retryable('rate_limited', 'games.list')).toBe(false);
|
||||
expect(retryable('rate_limited', 'auth.email.login')).toBe(false);
|
||||
});
|
||||
|
||||
it('retries only read-only ops on a transport unavailable (a mutation could double-apply)', () => {
|
||||
@@ -53,9 +58,12 @@ describe('retryable', () => {
|
||||
});
|
||||
|
||||
describe('isConnectionCode', () => {
|
||||
it('flags the transport/rate-limit codes the indicator covers', () => {
|
||||
it('flags only the transport connectivity code (a rate-limit is a surfaced message, not connectivity)', () => {
|
||||
expect(isConnectionCode('unavailable')).toBe(true);
|
||||
expect(isConnectionCode('rate_limited')).toBe(true);
|
||||
// A rate-limit must NOT read as connectivity: isConnectionCode gates both reportOffline (the
|
||||
// offline chrome) and handleError's toast suppression, so treating it as connectivity produced
|
||||
// a silent, stuck phantom offline on the login screen. It surfaces as error.rate_limited.
|
||||
expect(isConnectionCode('rate_limited')).toBe(false);
|
||||
expect(isConnectionCode('not_your_turn')).toBe(false);
|
||||
expect(isConnectionCode('internal')).toBe(false);
|
||||
});
|
||||
|
||||
+16
-11
@@ -2,11 +2,14 @@
|
||||
// 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).
|
||||
// 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';
|
||||
@@ -65,20 +68,22 @@ export const READ_OPS: ReadonlySet<string> = new Set([
|
||||
]);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 === '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). */
|
||||
* 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' || code === 'rate_limited';
|
||||
return code === 'unavailable';
|
||||
}
|
||||
|
||||
/** backoffMs is the delay before retry attempt n (1-based): capped exponential growth plus a
|
||||
|
||||
Reference in New Issue
Block a user