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

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:
Ilia Denisov
2026-07-13 22:48:59 +02:00
parent 7e142d471c
commit fe5a3d6d3b
5 changed files with 56 additions and 19 deletions
+2 -1
View File
@@ -121,7 +121,8 @@ web code exchange via `internal/vkid`, and both forward the trusted `external_id
Rate-limit defaults (built-in): public 30/min·IP (burst 10), authenticated
300/min·user (burst 80, sized for multi-device play), admin
60/min·IP (burst 20, guarding the `/_gm` mount ahead of its Basic-Auth),
email-code 5/10 min·IP (burst 2).
email-code 5/10 min·IP (burst 4, covering request + a mistyped code + the
correct one; defence-in-depth over the backend's per-code 5-attempt/15-min cap).
Every rejection increments `gateway_rate_limited_total{class}`
(`user`/`public`/`email`/`admin`) and logs one Debug line; a reporter drains the