Skip to content

fix(usage_limits): share one OAuth fetch schedule across cship processes - #208

Open
Romboter wants to merge 3 commits into
stephenleo:mainfrom
Romboter:fix/usage-limits-shared-coordinator
Open

Romboter wants to merge 3 commits into
stephenleo:mainfrom
Romboter:fix/usage-limits-shared-coordinator

Conversation

@Romboter

Copy link
Copy Markdown
Contributor

Follow-up to #95. With several Claude Code sessions open, each cship process polled /api/oauth/usage on its own per-transcript schedule, which gets the account rate limited (HTTP 429). A 429 was not remembered, so the next render tried again. This change makes every cship process that uses the same Claude config directory share one fetch schedule.

On each render, cship reads the shared state. If the data is fresh enough, it renders that. Otherwise it tries the lock. The process that gets the lock fetches on a worker thread (the request times out at 1.5 s, and the render waits at most 2 s) and writes the result back. Processes that do not get the lock render the last saved data.

Commit 1 changes how a fetch reports its result:

  • fetch_usage_limits returns a UsageFetchOutcome (success, 429 with Retry-After, 401, 5xx, network error, invalid response) instead of a bare Result.
  • Failures are logged with tracing::warn!.
  • Requests send User-Agent: cship/<version>. I checked the endpoint with claude-code/<version>, cship/<version> and no header at all, and it answered the same each time, so I used our own name.
  • The HTTP timeout is unchanged at 1500 ms.

Commit 2 adds the shared schedule, in src/usage_limits_state.rs:

  • One state file and one lock file live in <CLAUDE_CONFIG_DIR or ~/.claude>/cship/cache/, readable only by the owner on Unix. An OS advisory lock lets one process fetch while the others render the last shared data.
  • After an error, cship waits before trying again, and never for less than ttl. A 429 waits for Retry-After, up to 15 minutes. A 5xx waits 30 s, then doubles with each repeat up to 5 minutes. A network error waits 30 s, or ttl if that is longer. A 401 stops fetching until the token changes.
  • Main refreshed the cache when a usage window reset, and that still happens: the next fetch is scheduled a few seconds after the reset. A reset never causes two fetches closer than 30 s apart (or ttl, if that is shorter), and it never delays a regular poll by more than 30 s.
  • A window at 0% is no longer treated as missing, and 5h and 7d values from different sources are no longer paired up.
  • ttl defaults to 60 s as before. ttl = 0 still loads and is raised to 1 s with a warning.
  • Cargo.toml gets rust-version = "1.89", because File::try_lock needs it.
  • cship explain reads the shared state. The FAQ usage section and the ttl row in docs/configuration.md are updated.

No config fields are added or removed, the default ttl is unchanged, and there are no new dependencies. Output is the same as before when nothing is failing.

Two behaviours differ from main:

  • If the OAuth token cannot be read during one render, the module renders nothing for that render. Main showed the old cached data. The next successful read recovers it.
  • Sharing is per Claude config directory, not per account. Two config directories get two schedules.

Not covered:

The coordinator takes its directory and its fetch function as arguments, so 13 tests call the real function with a temporary directory and a fake fetch. I checked that they fail when the function is broken by deleting lines from it. The scheduling, backoff and data-merge paths also have unit tests. To try it by hand, log out of Claude Code and back in. After a 401, fetching resumes once the token changes.

Verification

  • cargo fmt --check ✅
  • cargo clippy --all-targets -- -D warnings ✅
  • cargo test ✅ (613 unit + 77 integration, run on Linux)
  • cargo build --release ✅

🤖 Generated with Claude Code

Romboter and others added 3 commits October 10, 2026 20:48
…etry-After parsing

Sets ureq's http_status_as_error(false) so 401/429/5xx responses reach
fetch_usage_limits's status-code branches instead of being converted to
Err(Error::StatusCode) beforehand and silently downgraded to NetworkError,
which would defeat the token-failure and rate-limit backoff logic that
consumes UsageFetchOutcome. Also extracts status-code classification into a
pure, unit-testable classify_status() function.
Every cship process (one per Claude Code session, re-run on each render)
fetched /api/oauth/usage independently and cached per transcript. With
several sessions open this produced redundant calls, 429s, and empty first
renders, with no shared backoff. Follow-up to stephenleo#95, which made the interval
configurable but left each process fetching on its own.

Fetches now go through usage_limits_state::resolve_shared_usage, which
keeps one state file per Claude config directory (atomic write,
schema-versioned, owner-only on Unix):

- Cross-process exclusion uses an OS-level advisory lock
  (File::try_lock, stable since Rust 1.89, so Cargo.toml now declares
  rust-version = "1.89"). The OS releases it if the holder crashes, so
  there is no stale-lock bookkeeping.
- Eligibility: not blamed for the current token's last 401, past any
  rate-limit cooldown, past next_allowed_at. 429 (honouring Retry-After)
  and 5xx back off exponentially; a 401 blames the token fingerprint so
  retries stop until the token changes, then exactly one probe is allowed.
- Errors never make polling more frequent than ttl: every backoff is
  floored at the configured interval.
- Window resets: like the old cache, which expired the moment a window
  reset, the next poll is scheduled for 5s after a known reset when that
  comes before the regular poll, or within 30s after it (a regular poll
  would only see the old window). Polls are never closer than
  min(30s, ttl), and a regular poll is never delayed by more than that.
- When a fetch starts, the lock file records the token fingerprint and
  mtime of the attempt. This is an independent throttle: an unwritable or
  AV/sync-blocked state file cannot cause a fetch on every render. A fresh
  lock file or a different token does not count as an attempt, so the
  first render and an account switch are never throttled by it.
- The old worker-thread + 2s bounded wait is kept
  (fetch_with_render_timeout, HTTP timeout stays at 1500ms), so a slow API
  cannot stall a render. Fetch failures are logged at warn.
- Credentials are only read, and the API only called, when the input has a
  transcript_path, so non-session input never touches a real credential.

ttl defaults to 60s and has no floor. The coordinator dedupes fetches
across sessions, so ttl only sets overall polling frequency. ttl = 0 would
disable throttling, so it is clamped to 1s (with a warning) instead of
being a fatal config error. Docs updated: README, configuration, faq (new
state file location, backoff behaviour), showcase.

Two display bugs are fixed on the same path:

- A window that just reset reports utilization 0 and resets_at null; this
  was indistinguishable from a plan with no such window (Enterprise).
  five_hour_available / seven_day_available now record presence, and
  lacks_standard_signal checks presence rather than value.
- A stale percentage was paired with a newer reset time (or vice versa)
  when merging sources, yielding "resets in now". UsageWindowSnapshot /
  merge_window keep each pct and reset timestamp together, in all source
  arms.

Removes the per-transcript usage cache from cache.rs and its tests, and
points `cship explain`'s usage_limits hint at the shared state, checking
the token fingerprint and with the hint logic unit-tested directly. Adds
platform::claude_config_dir and cship_shared_cache_dir. Cache schema is v3
so older entries are not misread.

The coordinator is tested through resolve_shared_usage_in, which takes the
cache directory and fetch function as parameters (the public function is a
thin wrapper), against a temp dir and a fake fetch. Tests were checked by
mutating the function and confirming they fail. A test-only seam in the
usage_limits module covers the stdin + API data assembly.

Not addressed: usage_limits still reports OAuth quota in sessions that use
another provider (stephenleo#199); the coordinator is per Claude config directory,
not provider-aware.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@stephenleo

Copy link
Copy Markdown
Owner

@Romboter thanks for the PR! Here are the findings from a code review. #1–#3 are the most important.

Correctness

  1. The post-reset poll gets blocked (src/usage_limits_state.rs:541): recently_attempted compares the lock file's mtime against the full interval. With ttl=60, the poll scheduled at t0+30 after a window reset is skipped, so the new window only shows up at t0+60. With ttl=300 the "poll just after reset" feature never runs at all. Suggest comparing against the scheduled spacing (e.g. MIN_POLL_SPACING_SECS.min(interval)) instead.
  2. A non-contention lock failure turns off fetching for good (src/usage_limits_state.rs:532): if the cache dir can't be created, the lock file can't be opened, or try_lock hits an I/O error (ENOLCK on NFS or some WSL mounts), try_acquire_fetch_lock_in returns None on every render and nothing is ever fetched. Main fetched fine in this setup. This also logs at debug level (or not at all) instead of tracing::warn!.
  3. The lock is released before the state is saved (src/usage_limits_state.rs:443): the worker drops _lock right after sending the outcome, before the main thread runs persist_state_to_dir. Another process can take the lock and read the old, still-eligible state. Only the mtime backstop prevents a duplicate fetch, and it doesn't when interval is 1 or during a token-change probe. Suggest holding the lock until the state has been saved.
  4. Alternating tokens get unthrottled fetches (src/usage_limits_state.rs:564): two processes sharing a cache dir with different tokens (A/B) each see a fingerprint mismatch on every render. That skips both the schedule and the 429 cooldown, so they fetch on every render.
  5. The schedule key doesn't match the credential used (src/platform.rs:124): the state is keyed by CLAUDE_CONFIG_DIR, but get_oauth_token ignores it (it uses the fixed keychain service or ~/.claude/.credentials.json). Sessions with different CLAUDE_CONFIG_DIR values share one token but keep separate schedules, so 429s can still happen. Keying by token fingerprint would fix this.
  6. One 401 stops fetching until the token rotates (src/usage_limits_state.rs:382): failed_token_fingerprint has no retry timer, so a one-off 401 leaves the data frozen, possibly for hours. A long backoff (e.g. MAX_RATE_LIMIT_BACKOFF_SECS) would recover on its own.
  7. Permanent 4xx errors retry forever (src/usage_limits.rs:177): classify_status maps 403 and 404 to ServerError, so they retry every 30–300s and log a warning each time. Treating non-429 4xx responses like 401 would stop this.

Cleanup

  1. UsageWindowSnapshot and MergedUsageWindow are the same struct, with a From impl, empty(), and unknown_result all doing the same job (src/modules/usage_limits.rs:21). One #[derive(Default, Clone)] struct would do.
  2. error_hint_for still takes a parameter it no longer uses (_ctx). It can be dropped (src/explain.rs:253).
  3. persist_state_to_dir calls sync_all on the render hot path, which is F_FULLFSYNC on macOS. The state is a disposable cache, so the atomic rename is enough (src/usage_limits_state.rs:109).

🤖 Generated with Claude Code

This branch has not been deployed

No deployments
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.

2 participants