fix(usage): keep Claude backoff when 429 carries Retry-After: 0

RA
12h ago
+141-16

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-After can 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_at against a local HTTP server that always answers 429 + the given Retry-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 contains retry 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_write used a Retry-After: 0 429 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 (writes observed_at = 1060 into the state file). All original assertions are kept. Removing the (observed_at, revision, sample.is_none()) tie-break in state::load still makes it fail with rejected quota resurrected from equal-time disk state.
  • cargo test -p tokscale-cli, cargo fmt --all -- --check, and cargo clippy -p tokscale-cli --all-targets --no-deps -- -D warnings all 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:

BuildOutput per runRequests received
mainHTTP 429; retry in 0s ×33
this branchHTTP 429; retry in 60s → rate-limited; retry in 53s → rate-limited; retry in 46s1

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.
  • failures resets 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 server Retry-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.