Skip to content

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

Open
raw34 wants to merge 1 commit into
junhoyeo:mainfrom
raw34:fix/claude-retry-after-zero-backoff
Open

raw34 wants to merge 1 commit into
junhoyeo:mainfrom
raw34:fix/claude-retry-after-zero-backoff

Conversation

@raw34

@raw34 raw34 commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

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:

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.
  • 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.

Written for commit 6a9868d. Summary will update on new commits.

View guided diff

The Claude usage endpoint can answer a 429 with `Retry-After: 0`. `State::limited` used 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.

The same-second auth-rejection test used a `Retry-After: 0` 429 to get a second request into the same clock second. It now builds that equal-second disk state directly, keeping its original assertions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
tokscale Ready Ready Preview Nov 2, 2026 10:44am UTC

Request Review

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

View guided diff | Re-trigger cubic

This branch was successfully deployed

1 active deployment
Preview — 6a9868d5 Deployed Nov 2, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant