fix(usage): keep Claude backoff when 429 carries Retry-After: 0
Summary
The Claude usage endpoint (/api/oauth/usage) can answer HTTP 429 with Retry-After: 0. State::limited used that value verbatim, so the cooldown expired in the same second it was set: the state file got until = now and the cooldown file got expires_at = now. Every TUI refresh (auto-refresh, r, click) then sent another request while the limit was still in force, and the Usage tab kept showing HTTP 429; retry in 0s.
Follow-up to #1557 and #1188, which added the cooldown machinery. This PR closes the case where the server's own hint disables it.
What changed
crates/tokscale-cli/src/commands/usage/claude_state.rs:Retry-Aftercan now only lengthen the progressive backoff. It no longer replaces it:wait = min(max(retry_after, 60s × 2^(failures-1)), 3600s).Retry-After: 0, an HTTP-date already in the past, or any value below the backoff → 60s, 120s, 240s, … up to 1h.- A larger server value (e.g.
Retry-After: 300) is still honored as-is. - A successful fetch still resets the failure count.
- No change to the 60s fresh-sample window, state keying, cooldown storage, or TUI code. Every refresh path already goes through the existing pre-request cooldown check in
fetch_policy, so this one change covers all of them.
Testing
- Unit tests on
State::limited: zero, a value below the backoff, a value above it, and repeated zeros capping at 3600s. - Integration tests through
fetch_report_atagainst a local HTTP server that always answers429+ the givenRetry-After, asserting on the number of requests that actually reached the server:policy_zero_retry_after_blocks_requests_until_backoff_elapses: refreshes at +0/+30/+60/+120/+180s send requests only at +0, +60, +180, with waits of 60s, 120s, 240s. No diagnostic containsretry in 0s.policy_zero_retry_after_cooldown_survives_restart: drops both in-memory maps to simulate a new tokscale process, and confirms no request goes out at +30s.policy_past_http_date_retry_after_backs_off
- With the fix reverted, all three integration tests fail.
policy_same_second_auth_rejection_survives_failed_state_writeused aRetry-After: 0429 to land a second request in the same clock second, which is exactly the behavior this PR removes. It now builds that equal-second disk state directly (writesobserved_at = 1060into the state file). All original assertions are kept. Removing the(observed_at, revision, sample.is_none())tie-break instate::loadstill makes it fail withrejected quota resurrected from equal-time disk state.cargo test -p tokscale-cli,cargo fmt --all -- --check, andcargo clippy -p tokscale-cli --all-targets --no-deps -- -D warningsall pass.
Verification
Built the binary with USAGE_URL temporarily pointed at a local server that always returns 429 + Retry-After: 0. Ran tokscale usage --light three times, ~7s apart, each as a separate process with an isolated TOKSCALE_CONFIG_DIR:
| Build | Output per run | Requests received |
|---|---|---|
main | HTTP 429; retry in 0s ×3 | 3 |
| this branch | HTTP 429; retry in 60s → rate-limited; retry in 53s → rate-limited; retry in 46s | 1 |
The real endpoint's 429 can't be reproduced on demand, so this was verified against the local stand-in only.
Notes
- The quota on this endpoint is likely shared with Claude Code itself, which uses the same OAuth token. An occasional 429 can still appear. With this change it shows a real countdown, keeps serving the cached sample, and stops re-requesting on every refresh.
failuresresets only on a successful fetch, not with time. If seven or more 429s happen in a row with no success in between, a later short serverRetry-After(e.g. 30s) is raised to the 1h cap. This is conservative and matches how header-less 429s already escalate. A time-based decay could be a separate follow-up if wanted.
🤖 Generated with Claude Code
Summary by cubic
Fixes the Claude usage endpoint cooldown so a 429 with Retry-After: 0 no longer disables backoff. State::limited honored the header verbatim, so the cooldown expired in the same second and every TUI refresh re-requested while the limit was still in force, showing “HTTP 429; retry in 0s”. Retry-After now only lengthens the progressive backoff (60s doubling, capped at one hour); a larger server value is still honored as-is. The same-second auth-rejection test is updated to build the equal-second disk state directly instead of relying on a Retry-After: 0 429.