fix(login): rate-limit no longer latches a phantom offline on the login screen #255

Merged
developer merged 1 commits from feature/login-rate-limit-offline into development 2026-07-13 20:59:22 +00:00
Owner

Symptom

A fresh email login on the PWA: request a code, mistype it once (correct red "invalid code" toast), then enter the correct code — and the app claims you are offline. Buttons disable for ~20 s, and the state is unrecoverable: it survives a full PWA close/reopen + a fresh code, even though the network is fine the whole time.

Root cause

The gateway email class (auth.email.request + auth.email.login, keyed per client IP, 5/10 min, burst 2) trips on the third event. In the natural request code → wrong code → correct code sequence that third event is the correct-code login, so the gateway answers ResourceExhausted.

The client maps that 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 into offlineNoNetwork. The transport kill switch (assertOnline) then refuses the very login that would fix it. Because the IP's email bucket refills only 1 token / 120 s, every 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 removes the ~20 s button freeze and stops 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 (emailCodeMaxAttempts = 5 + 15-min TTL + per-recipient send throttle), which remain the real brute-force barrier.

Tests

  • retry.test.ts: rate-limit classification updated to the new semantics (not retried, not a connection code).
  • ratelimit_test.go: a regression guard asserts the honest three-event email flow passes under the default policy (so the burst can't be silently re-tightened below the login sequence).
  • gateway/README.md rate-limit note updated.

Known residual (deferred by the owner)

The net-state recovery probe is session-gated, so any real transport failure on the session-less login/confirm screens still cannot self-heal (it just no longer fires spuriously on a rate-limit). Left as a follow-up.

Verification

  • Gateway: go build / go vet / gofmt clean; full go test ./gateway/... green (incl. connectsrv abuse + the new guard).
  • UI: vitest run 619 passed / 9 skipped; svelte-check 0 errors / 0 warnings.
  • Not exercised end-to-end against the real gateway rate-limit (needs the contour); the mock e2e can't reproduce it (net-state machine is inert under mock).
## Symptom A fresh email login on the PWA: request a code, mistype it once (correct red "invalid code" toast), then enter the **correct** code — and the app claims you are **offline**. Buttons disable for ~20 s, and the state is **unrecoverable**: it survives a full PWA close/reopen + a fresh code, even though the network is fine the whole time. ## Root cause The gateway email class (`auth.email.request` + `auth.email.login`, keyed **per client IP**, `5/10 min, burst 2`) trips on the **third** event. In the natural `request code → wrong code → correct code` sequence that third event is the **correct-code login**, so the gateway answers `ResourceExhausted`. The client maps that 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 into `offlineNoNetwork`. The transport kill switch (`assertOnline`) then refuses the very login that would fix it. Because the IP's email bucket refills only 1 token / 120 s, every 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 removes the ~20 s button freeze and stops 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 (`emailCodeMaxAttempts = 5` + 15-min TTL + per-recipient send throttle), which remain the real brute-force barrier. ## Tests - `retry.test.ts`: rate-limit classification updated to the new semantics (not retried, not a connection code). - `ratelimit_test.go`: a regression guard asserts the honest three-event email flow passes under the **default** policy (so the burst can't be silently re-tightened below the login sequence). - `gateway/README.md` rate-limit note updated. ## Known residual (deferred by the owner) The net-state recovery probe is session-gated, so **any** real transport failure on the session-less login/confirm screens still cannot self-heal (it just no longer fires spuriously on a rate-limit). Left as a follow-up. ## Verification - Gateway: `go build` / `go vet` / `gofmt` clean; full `go test ./gateway/...` green (incl. connectsrv abuse + the new guard). - UI: `vitest run` 619 passed / 9 skipped; `svelte-check` 0 errors / 0 warnings. - Not exercised end-to-end against the real gateway rate-limit (needs the contour); the mock e2e can't reproduce it (net-state machine is inert under mock).
developer added 1 commit 2026-07-13 20:49:47 +00:00
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
fe5a3d6d3b
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.
owner approved these changes 2026-07-13 20:58:18 +00:00
developer merged commit f6d256215a into development 2026-07-13 20:59:22 +00:00
developer deleted branch feature/login-rate-limit-offline 2026-07-13 20:59:22 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: developer/scrabble-game#255