Skip to content

fix(usage): fingerprint the usage cache by refresh token so a routine token refresh is not read as an account switch - #536

Merged
sirmalloc merged 3 commits into
sirmalloc:mainfrom
emotionalboySY:fix/stale-cache-through-token-refresh
Sep 17, 2026
Merged

sirmalloc merged 3 commits into
sirmalloc:mainfrom
emotionalboySY:fix/stale-cache-through-token-refresh

Conversation

@emotionalboySY

Copy link
Copy Markdown
Contributor

Symptom

A server-side 429 with Retry-After: 3600 left every usage widget rendering [Rate limited] for the full hour, even though usage.json held a complete, usable snapshot the entire time. The stale-cache fallback that exists for exactly this case never fired.

7d: [Rate limited] | 7d-F: [Rate limited]
// ~/.cache/ccstatusline/usage.json, written 2h38m earlier
{"sessionUsage":0,"weeklyUsage":8,"weeklyResetAt":"2026-08-07T12:00:00Z",
 "weeklySonnetUsage":0,"weeklyOpusUsage":0,"fableUsage":16,
 "fableResetAt":"2026-08-07T12:00:00Z","tokenHash":"fe98a0af7b2e6fe5"}

// ~/.cache/ccstatusline/usage.lock
{"blockedUntil":1785803730,"error":"rate-limited"}

Root cause

The cache fingerprint hashes claudeAiOauth.accessToken. Per the comment on fingerprintUsageToken, its job is to catch a login switch:

persisted alongside the cache so a login switch (e.g. enterprise<->personal, a different token) invalidates the cache immediately instead of waiting out the TTL

But the access token is reissued on a short cycle, so an ordinary refresh of the very same account changes the fingerprint too, and readStaleUsageCache() discards the snapshot as if the account had switched. On my machine, minutes apart:

value
fingerprint stored in the cache fe98a0af7b2e6fe5
fingerprint of the live access token 25a70170398d1eb1
expiresAt of that access token ~8h out, reissued after the cache was written
refreshTokenExpiresAt ~13 days out

Same account, same subscription — the session token simply rotated.

This is worst exactly when the cache is load-bearing. Once a backoff lock is active, every render goes through getStaleUsageOrError(), finds the fingerprint mismatched, drops a valid snapshot and returns error text until the lock expires — up to the server's Retry-After.

In practice the widgets left visibly broken are the per-model ones (weekly-opus-usage, fable-weekly-usage), since session / weekly_all can still be served from the stdin rate_limits payload.

Fix

Fingerprint the refresh token instead. It identifies the login rather than the session — roughly two orders of magnitude longer-lived (~13 days vs ~8 hours in the values above) — so the account-switch guard keeps working without churning every few hours. It falls back to the access token when absent, so older credential files and any keychain entry that stores only an access token behave exactly as before.

getUsageToken() keeps its signature and still returns the access token used for the API call; the credentials record it is now built from carries the refresh token purely for fingerprinting.

What I did not do

My first attempt was to relax the fallback instead — serve the fingerprint-mismatched cache while a transient error (rate-limited / timeout / api-error) is in effect. Two existing tests deliberately pin the opposite behavior:

  • does not serve a mismatched account cache during an active lock
  • does not serve a mismatched account cache during a rate-limit backoff

Those assertions are right — a mismatched fingerprint should not be served. The bug is that a refresh produces a mismatch at all, so this fixes the fingerprint rather than weakening the guard. Both tests still pass unmodified.

Compatibility

Caches written by earlier versions carry an access-token hash, so the first run after upgrading sees one mismatch and refetches once, then stores the new fingerprint. No user-visible effect beyond a single extra request.

Tests

Three added to the fetchUsageData error handling probe suite:

  • serves a fresh cache after the access token was refreshed (same login) — refresh token unchanged, access token reissued; cache is served with requestCount 0.
  • serves a stale cache through a rate-limit backoff after the access token was refreshed — the reported scenario: cache past CACHE_MAX_AGE, active rate-limited lock, refreshed access token. Asserts the snapshot renders and no request is made (the backoff is still honored).
  • refetches when the refresh token changes (account switch) — a different login still invalidates immediately.

bun test src/utils/__tests__/: 791 pass / 3 fail, against a 788 pass / 3 fail baseline on an unmodified checkout — same three failures, all environment-dependent on my Windows machine (saves through a symlinked settings file..., silences child stderr on best-effort probes..., and preserves root errors within a process... timing out at 5000 ms). bun run lint (tsc + eslint) clean.

Related

#534 — different root cause (placeholder parsing of an unused model-scoped quota), same user-visible text. That one is about a field never reaching the cache; this one is about a cache that has the field being thrown away. They are independent and do not conflict.

🤖 Generated with Claude Code

The cache fingerprint hashes the OAuth access token, which is reissued on
a short cycle (observed ~8h expiry), so an ordinary refresh of the very
same account changes it and the cached snapshot is discarded as if the
account had switched.

That is worst exactly when the cache is load-bearing. While a fetch
failure is being backed off, getStaleUsageOrError finds the fingerprint
mismatched, drops an otherwise valid snapshot and returns error text for
the whole window - observed as [Rate limited] on every usage widget for a
server-issued Retry-After of 3600s, with complete usage data sitting in
usage.json the entire time.

Fingerprint the refresh token instead: it identifies the login rather than
the session (observed ~13 day expiry, two orders of magnitude longer), so
the account-switch guard stays intact without churning on every refresh.
It falls back to the access token when absent, keeping older credential
files and access-token-only keychain entries working as before.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@sirmalloc
sirmalloc merged commit 282c5a3 into sirmalloc:main Sep 17, 2026
3 checks passed
pcvelz added a commit to pcvelz/ccstatusline-usage that referenced this pull request Sep 25, 2026
Upstream: TUI kept off the render path (sirmalloc#575), flex mode default full (sirmalloc#590), usage cache fingerprinted by refresh token (sirmalloc#536), CLAUDE_CONFIG_DIR keychain credential first (sirmalloc#573), llms.txt (sirmalloc#527), faster terminal width probing (sirmalloc#501), git/jj symbol slots (sirmalloc#574), model-scoped 0% quota as real zero (sirmalloc#534), custom-command output cache + timeout (sirmalloc#539), usage-percent widgets on a shared module (sirmalloc#545), hideable reset-timer placeholders (sirmalloc#542), git command timeouts (sirmalloc#559, sirmalloc#585)

Hand edits outside conflicts:
- src/widgets/shared/usage-percent-widget.ts: compat fix - pass RenderContext to getUsageProgressBarWidth (fork narrow/medium bar widths) and add fork short labels WS:/WO: that the extracted Sonnet/Opus widgets used to render; point the fable-weekly kind at the fork field weeklyFableUsage / resolveWeeklyFableUsageWindow (upstream's fableUsage / resolveFableUsageWindow do not exist in the fork and crashed the render)
- .fork-keep-deleted: drop llms.txt (points agents at the upstream package and at docs the fork deletes)

Conflict resolutions that deviate from upstream on purpose:
- src/types/Settings.ts: keep fork default flexMode full-minus-40 (upstream sirmalloc#590 switched to full)
- src/utils/terminal.ts: keep fork tmux $TMUX_PANE width probe, ported to execFileSync; upstream's CCSTATUSLINE_WIDTH override in getTerminalWidth replaces the fork copy
- src/ccstatusline.ts: keep getTerminalWidth() without the per-session width cache options (fork invariant); add upstream customCommandCacheTtlSeconds
- src/widgets/WeeklyFableUsage.ts (+ test): keep fork widget (Fable:/F: labels, 0% for accounts without Fable, weeklyFableUsage field) instead of upstream's shared-module FableWeeklyUsage
- src/utils/__tests__/usage-fetch.test.ts: adopt upstream sirmalloc#534 real-zero semantics on the fork field weeklyFableUsage
- docs/test-retirements.md: ledger entries for four tests upstream renamed or replaced (sirmalloc#542, sirmalloc#534) and one duplicate upstream test dropped
- src/utils/__tests__/usage-fetch.test.ts: compat - upstream's model-scoped real-zero test expects the fork field weeklyFableUsage
- src/widgets/__tests__/WeeklyFableUsage.test.ts: compat - set the shared suite's new required expectedWholePercentTime
- src/tui/components/__tests__/ImportPreviewDialog.test.ts: fork deviation - default flexMode is full-minus-40, so the non-default side is full
- src/utils/__tests__/usage-fetch.test.ts: drop upstream's duplicate "missing fable window" test that used the upstream-only fableUsage field
thariman added a commit to thariman/ccstatusline that referenced this pull request Sep 27, 2026
Conflict resolutions:
- usage-fetch: adopt upstream's CLAUDE_CONFIG_DIR keychain service
  lookup (sirmalloc#573), which supersedes the fork's getConfigDirKeychainService;
  keep the fork's per-config-dir usage cache/lock paths, now keyed on
  upstream's cacheIdentity fingerprint (sirmalloc#536)
- usage tests: take upstream's usage-token tests; point upstream's new
  cache-seeding helpers at the fork's per-config-dir cache filenames
- package.json: keep @thariman/ccstatusline, bump to 2.2.32
- README: keep fork notes alongside upstream's v2.2.29-v2.2.30 notes

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EchK7KtaafjsNhMkNamXqj
eric-engberg added a commit to eric-engberg/ccstatusline that referenced this pull request Oct 26, 2026
The forecast history was kept per Claude config directory, and a reading
joined the window whose reset was within a minute of its own. After
/login to another account in the same directory, that account's block
can reset at the same moment as the last one's, since the usage API's
resets fall on whole 10-minute marks. Its readings then joined the
first account's window, where a lower percent counted as a stale
session's and was dropped, and the forecast went from the first
account's percent: switching from an account at 100% to one at 10% hid
Block Forecast and Block Limit Timer until the reset.

The history now goes by config directory and login, the login being the
usage cache's fingerprint of its refresh token (sirmalloc#536). That stays the
same when Claude Code reissues the access token every few hours, and
changes with /login. getUsageLoginFingerprint reads it through the usage
widgets' credential lookup, including its 30-second backoff after
finding no login. Without one (a token from the environment, say), the
history goes by the config directory alone, as before.

On macOS that is one keychain read per render that has a forecast widget
and a 5-hour percent; a render whose percent comes from the payload
didn't read the keychain before.
sirmalloc added a commit that referenced this pull request Oct 29, 2026
…imer) (#699)

* feat(usage): forecast the 5-hour window from the recent pace

* feat(usage): keep a per-profile history of session usage readings

* feat(usage): record session usage and forecast once per render

* feat(usage): add the Session Forecast widget

* feat(usage): add the Session Limit Timer widget

* docs: describe Session Forecast and Session Limit Timer

* test(usage): load Session Limit Timer's test the way the reset timer tests do

* fix(usage): ignore a lower session percent from a stale session

* test(usage): check the forecast's place mid-line, and document separate placement

* test(usage): check the TUI preview never records a forecast reading

* refactor(usage): call the forecast widgets Block Forecast and Block Limit Timer

ccstatusline uses "session" for two things: the 5-hour usage window and
the Claude Code conversation (Session Clock, Session Cost, Session Name).
The timers for the 5-hour window already say "block" (Block Timer, Block
Reset Timer), so these two say it too: Session Forecast becomes Block
Forecast (`block-forecast`) and Session Limit Timer becomes Block Limit
Timer (`block-limit-timer`), with their files, classes, descriptions and
docs. Neither has been released, so no saved setting needs migrating.

The helpers that record and forecast the window keep their names
(session-forecast.ts, session-usage-history.ts, the history file): they
follow the usage data's `sessionUsage` field, which isn't user-facing.

* test(block-forecast): check the line without the usage widget's label

The test placed Block Forecast after Session Usage and matched the line
against "Session: 42.0%", so it pinned that widget's label rather than
the forecast's place on the line. Show the usage widget's raw value
instead; the test checks the same thing whatever the label is.

* docs(block-forecast): refer to the 5-hour usage widget without its name

The docs said the forecast widgets sit right after Session Usage, which
another change renames Block Usage. Describe it as the 5-hour usage
widget instead, so the text reads right with either name; two test
titles follow.

* fix(block-forecast): label the limit timer as a countdown

Block Limit Timer printed `Limit: 1hr 13m`, which reads as the size of
the limit rather than the time until it's reached. It now prints
`Limit in: 1hr 13m` (`Limit in: 1h13m` short). Raw value mode is
unchanged.

* fix(usage): stop the forecast history's temp files piling up

The 5-hour usage history is saved by writing a temp file named after the
process id and renaming it over session-usage-history.json. When the
rename failed, for example with EPERM while a Windows virus scanner or
sync client held the new file, the error was ignored and the temp file
stayed. Every status line render is a new process with a new temp name,
so ~/.cache/ccstatusline gained another session-usage-history.json.<pid>.tmp
about once a minute, and nothing ever removed them.

A failed write now unlinks its temp file, ignoring a failed unlink. The
handle that broke the rename can refuse that unlink too, and earlier
builds have already left orphans behind, so each write also removes the
history's other <pid>.tmp files that are a minute or more old. The age
keeps it off a temp file another render is still writing. While a handle
stays held, only the last minute's temp files remain, and those from
earlier builds go on the next write.

Each render keeps its own temp name, so two renders writing at once
never write into the same file.

* fix(usage): forecast an idle session's block from the latest reading

Sessions of one Claude config directory share the 5-hour history. An
idle session's payload keeps the percent from its own last response, so
while another session took the account from 30% to 50%, the idle one
still said 30%. The history already ignores that lower percent, but the
forecast still put it at now, after the 50% reading. The rise over the
last 30 minutes came out negative or too small, so in the idle session
Block Forecast and Block Limit Timer showed nothing, or a projection and
limit time well behind the active session's for the same account.

Usage never falls within a block, so the forecast now goes from the
higher of the session's percent and the latest reading already seen. It
hands that percent on, and Block Forecast compares its projection with
it rather than with the session's own when deciding whether the
projection only repeats the current value, so both sessions hide the
same near-flat projection.

* fix(usage): keep another login's 5-hour block apart in the forecast history

The forecast history is kept per Claude config directory, and a reading
joined any window of it whose reset time was within an hour. After
/login to another account in the same config directory, that account's
block resets at a different time, and when that was within the hour of
the first account's reset, its readings landed in the first account's
window. Being lower than that account's last reading, they were all
dropped as a stale session's, and Block Forecast and Block Limit Timer
showed nothing, then too little once the new account passed the old
one's percent, until the old block reset.

One block's reset only differs between the usage API and the status
payload by fractions of a second, so a reading now joins a window only
when its reset is within a minute. Another login's block gets a window
of its own, unless both blocks reset at the same moment. The 5-hour
resets the usage API has sent so far fall on a whole 10-minute mark; if
blocks are rounded that way, two unrelated ones can share a reset
exactly, and no tolerance tells those apart.

* fix(usage): stop an idle session's refresh renders from flattening the forecast

Claude Code's refreshInterval, 10 seconds on a default install,
re-renders an idle session with the 5-hour percent from its last
response. An unchanged percent moved its reading's last-seen time to now
once a minute, so a session idle from 12:50 to 14:30 recorded "20% until
14:29". When usage on claude.ai or another machine meanwhile took the
account to 60%, the next response's reading made the whole climb look
like it happened in the last 30 minutes, instead of being spread over
the gap as a stretch without renders is: Block Limit Timer said "Limit
in: 30m" rather than 1hr 40m, and at 40% Block Forecast said →100.0%
with a limit time instead of →70.0%.

A percent from the payload now moves its reading's last-seen time no
later than the session's last API response: the timestamp of the
transcript's last assistant message, leaving out the ones Claude Code
writes itself, such as an API error's. The transcript's write time
wouldn't do: a prompt is written before its response arrives, and so is
a local command's output, while the renders they trigger still carry the
old percent. When the transcript holds no response, as a new one after
/clear doesn't until its first, the percent is from before it began, and
the reading's last sighting stays where it was. An idle stretch
therefore stays a gap. A percent from the usage API, used when the
payload has none, is current and still moves the reading to now. The
transcript is only read once a reading's last sighting is a minute old.

* fix(usage): keep a carried-over bar off the forecast widgets' editor rows

Changing a widget's type in the line editor keeps its metadata. Coming
from Block Usage or a reset timer in a bar mode, Block Limit Timer's row
read "(long bar, inverted)" while it rendered "Limit in: 1hr 13m", and
Block Forecast's "(long bar, remaining)" while it rendered "→16.8%".
Neither widget draws a bar or offers (p) to clear one, and the bar mode
also hid "compact" on Block Limit Timer, which still applied it.

Both rows now go through a shared helper for text-only usage widgets
that leaves out the bar mode, and inverting its fill unless the widget
shows used or remaining, before naming the modifiers.

* fix(usage): date a cached usage API percent by when the API sent it

When the payload carries no 5-hour percent, the forecast history took
the usage API's as seen at the render. fetchUsageData serves its cache,
though: up to three minutes old normally, and during a rate limit or an
API failure, whatever the cache holds, which can be hours old. Each
render then moved that old reading's last sighting to now. The history
showed the percent unchanged until fetching recovered, which flattened
the forecast meanwhile, and the climb since then looked like it happened
in the last few minutes, which exaggerated the pace afterwards.

fetchUsageData now keeps when the API sent the values it returns: now
for a fresh answer, the cache file's write time for a cached one.
getUsageFetchedAtMs exposes it, and the history dates an API percent by
it, as it already dates a payload percent by the transcript's last
response. The stretch since then stays a gap that a later change is
spread across.

A new percent is now also dated by when it was seen rather than when it
is first written, from either source: a profile can find another
profile's fetch in the shared usage cache, say. It is never dated before
the last reading's last sighting.

* fix(usage): lock the forecast history while a render rewrites it

The renders of every session and profile share
session-usage-history.json. A render read the whole file, added its
reading and renamed a new file over it, so when two renders overlapped,
the later rename put back what it had read: the other render's new
reading was lost, or a whole profile it had just added. Unique temp file
names only kept the JSON from tearing.

A render with something to write now creates
session-usage-history.json.lock exclusively, reads the history again
under it, writes, and removes the lock. A render that finds the lock
held doesn't wait: it forecasts from what it read and leaves the write
to its next render, which records the same percent with the same time,
since a reading is dated by when its percent was seen. A lock more than
10 seconds old, or dated that far ahead, was left by a render that died
holding it, and is taken over. Renders with nothing to write don't take
the lock.

* fix(usage): keep each login's 5-hour block apart in the forecast history

The forecast history was kept per Claude config directory, and a reading
joined the window whose reset was within a minute of its own. After
/login to another account in the same directory, that account's block
can reset at the same moment as the last one's, since the usage API's
resets fall on whole 10-minute marks. Its readings then joined the
first account's window, where a lower percent counted as a stale
session's and was dropped, and the forecast went from the first
account's percent: switching from an account at 100% to one at 10% hid
Block Forecast and Block Limit Timer until the reset.

The history now goes by config directory and login, the login being the
usage cache's fingerprint of its refresh token (#536). That stays the
same when Claude Code reissues the access token every few hours, and
changes with /login. getUsageLoginFingerprint reads it through the usage
widgets' credential lookup, including its 30-second backoff after
finding no login. Without one (a token from the environment, say), the
history goes by the config directory alone, as before.

On macOS that is one keychain read per render that has a forecast widget
and a 5-hour percent; a render whose percent comes from the payload
didn't read the keychain before.

* docs(block-forecast): drop the advice against merging Block Usage into it

The docs said to add Block Forecast and Block Limit Timer as separate
widgets, since a merge carried on across a widget that rendered nothing:
a hidden forecast joined Block Usage to whatever came next. Since #698,
a merge ends at a hidden widget that isn't merged onward, so merging
Block Usage into the forecast (with the forecast's label set to ` →`,
say) reads as one value while the forecast shows and leaves the line as
it was while it's hidden.

---------

Co-authored-by: Matthew Breedlove <[email protected]>
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