fix(e2e): back off on a 429 from the register-challenge rate floor #440

Merged
nrupard merged 2 commits from fix/BUNYIP-449-e2e-register-challenge-429-backoff into main 2026-08-03 16:10:00 +02:00
Owner

BUNYIP-449 (relates BUNYIP-445). Second of the split fixes from the e2e.yml failure triage.

Problem

registerDisposable (e2e/lib/accounts.ts) fetched GET /v1/auth/register-challenge and threw on the first non-ok status. On BUNYIP-445 run #2453 that GET returned 429 on the first hit (~510ms, empty body), failing magic-link.spec.ts and password-reset.spec.ts outright.

The 429 is not a stale-image artifact. On current main, register-challenge is not in rate_limit_floor::EXEMPT_PATHS, so it sits under the per-IP API_UNAUTH floor (RateLimitConfig::API_UNAUTH = 20 req / 60s / IP, crates/bunyip-domain/src/models/rate_limit.rs:115). The E2E runner has a single egress IP that shares that bucket, and the failing pushes came in bursts (#2453/#2457/#2463 within an hour), so the aggregate of unauthenticated requests across overlapping runs exceeds 20/min and the next register-challenge 429s. The handler itself sets no limit ("minting a signed timestamp is harmless", bunyip-api/src/handlers/auth.rs:387), but the floor still applies.

Change

Wrap the challenge GET in a bounded retry (fetchRegisterChallenge) that fires only on 429:

  • Honor the floor's truthful Retry-After header (rate_limit_floor.rs:139-140 sets it to the seconds until the window frees) up to a 65s ceiling, so one wait clears the 60s window.
  • Fall back to exponential backoff (1s / 2s / 4s, capped 15s) when the header is absent.
  • After 3 retries, throw with the status, Retry-After, and body snippet.
  • Log every 429 with the wait, so the run records the throttle window instead of a bare 429.

Any non-429 non-ok status still surfaces immediately (unchanged). The register POST and the bot-guard timing wait are untouched.

Scope / follow-up

This is the harness-side resilience. The durable root-cause fix is deployment/product-side and intentionally out of scope here: either exempt /v1/auth/register-challenge from the floor (which matches the handler's documented "needs no rate limit" intent) or raise RATE_LIMIT_API_UNAUTH_MAX_REQUESTS for staging. That is a security-reviewed change to file separately.

Verification

  • node_modules/.bin/tsc --noEmit (e2e, strict) passes.

🤖 Generated with Claude Code

BUNYIP-449 (relates BUNYIP-445). Second of the split fixes from the e2e.yml failure triage. ## Problem `registerDisposable` (`e2e/lib/accounts.ts`) fetched `GET /v1/auth/register-challenge` and threw on the first non-ok status. On BUNYIP-445 run #2453 that GET returned `429` on the first hit (~510ms, empty body), failing `magic-link.spec.ts` and `password-reset.spec.ts` outright. The 429 is not a stale-image artifact. On current main, `register-challenge` is not in `rate_limit_floor::EXEMPT_PATHS`, so it sits under the per-IP `API_UNAUTH` floor (`RateLimitConfig::API_UNAUTH` = 20 req / 60s / IP, `crates/bunyip-domain/src/models/rate_limit.rs:115`). The E2E runner has a single egress IP that shares that bucket, and the failing pushes came in bursts (#2453/#2457/#2463 within an hour), so the aggregate of unauthenticated requests across overlapping runs exceeds 20/min and the next `register-challenge` 429s. The handler itself sets no limit ("minting a signed timestamp is harmless", `bunyip-api/src/handlers/auth.rs:387`), but the floor still applies. ## Change Wrap the challenge GET in a bounded retry (`fetchRegisterChallenge`) that fires only on 429: - Honor the floor's truthful `Retry-After` header (`rate_limit_floor.rs:139-140` sets it to the seconds until the window frees) up to a 65s ceiling, so one wait clears the 60s window. - Fall back to exponential backoff (1s / 2s / 4s, capped 15s) when the header is absent. - After 3 retries, throw with the status, `Retry-After`, and body snippet. - Log every 429 with the wait, so the run records the throttle window instead of a bare `429`. Any non-429 non-ok status still surfaces immediately (unchanged). The register POST and the bot-guard timing wait are untouched. ## Scope / follow-up This is the harness-side resilience. The durable root-cause fix is deployment/product-side and intentionally out of scope here: either exempt `/v1/auth/register-challenge` from the floor (which matches the handler's documented "needs no rate limit" intent) or raise `RATE_LIMIT_API_UNAUTH_MAX_REQUESTS` for staging. That is a security-reviewed change to file separately. ## Verification - `node_modules/.bin/tsc --noEmit` (e2e, strict) passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(e2e): back off on a 429 from the register-challenge rate floor
All checks were successful
E2E PR gate / Install + reachability (no deployment secrets) (pull_request) Successful in 23s
Check / fmt + clippy + build + tests (pull_request) Successful in 11m17s
96d8734b6b
registerDisposable fetched GET /v1/auth/register-challenge and threw on the first non-ok status, so a transient 429 failed magic-link.spec.ts and password-reset.spec.ts outright (BUNYIP-445 run #2453). register-challenge is not in rate_limit_floor::EXEMPT_PATHS, so it sits under the per-IP API_UNAUTH floor (20 req/60s/IP); the E2E runner's single egress IP shares that bucket across overlapping runs, so a burst of main pushes can 429 it even though the handler itself sets no limit.

Wrap the challenge GET in a bounded retry that fires only on 429: honor the floor's truthful Retry-After header up to a 65s ceiling (just over the 60s window), fall back to exponential backoff (1s/2s/4s, capped 15s) when it is absent, and after 3 retries throw with the status, Retry-After, and body. Every 429 is logged with the wait so the run records the throttle window instead of a bare 429. Any non-429 non-ok status still surfaces immediately, and the register POST and bot-guard timing wait are unchanged.

The durable fix (exempting register-challenge from the floor, or raising the staging unauth limit) is a separate security-reviewed change; this is the harness-side resilience.

#BUNYIP-449

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(e2e): bound register-challenge backoff to the 60s per-test timeout
All checks were successful
E2E PR gate / Install + reachability (no deployment secrets) (pull_request) Successful in 24s
Check / fmt + clippy + build + tests (pull_request) Successful in 14m58s
Create release / Create release from merged PR (pull_request) Has been skipped
b3e57d89ac
The first cut honored Retry-After up to a 65s ceiling, but the disposable specs run in the retries:0 auth-ui project under a 60s per-test timeout (playwright.config.ts) and cannot take a whole-test retry without deepening the 5/min login limit. A single 65s wait would blow the test timeout, turning a fast 429 fail into a 60s timeout with no recovery. Cap each wait at 10s (~30s cumulative across the 3 retries), so the in-test backoff always fits the budget with margin. A full-window (up to 60s) floor throttle still cannot be waited out in-test; BUNYIP-450 (exempting the route from the floor) is the durable fix.

#BUNYIP-449

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard scheduled this pull request to auto merge when all checks succeed 2026-08-03 16:03:56 +02:00
nrupard deleted branch fix/BUNYIP-449-e2e-register-challenge-429-backoff 2026-08-03 16:10:01 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!440
No description provided.