fix(limits): a 4xx made the Codex chip disappear instead of saying why - #119
Merged
Conversation
Addresses Part 1 of issue 105 — the half it calls the higher-value one.
VERIFIED FIRST, as the issue asks. Drove getUsageLimits with a credentialed
temp home and a failing fetchImpl across four failure modes (HTTP 500, network
unreachable, 401, a body that is not JSON) plus a timeout, and read what each
provider returned.
Result: the suspicion in the issue — "what is untested is whether every
fetcher's failure path actually sets error rather than returning an
unconfigured-looking object" — is NOT borne out. Every failure path returns
{ configured: true, error: <message> }, and getUsageLimits wraps each provider
in a .catch that does the same. That is now locked in by tests rather than left
as a reading of the source.
ONE REAL DEFECT, and it is two issues disagreeing rather than a bug.
usage-limits.js:222 turns a 401/403/404 from wham into
{ primary_window: null, secondary_window: null }. Its comment says this renders
"a neutral empty state instead of a red Fetch failed" — the fix for issue 52,
where users who had not run `codex` for a week saw an alarming error.
But there was no neutral state to render. LimitChips has exactly two:
`data.error` gives a status line, anything else with no windows falls through to
`return null`. So the chip DISAPPEARED — the outcome issue 105 calls worse than
never having had a chip, because the user has been trained to look there and
reads absence as "plenty left".
Both issues actually want the same thing: not red, not gone. The UI just had
nowhere to put it. Added `notice` as the third state — same subtle grey line as
an error, neutral wording, actionable: "No usage data for this sign-in. Run
`codex` to sign in again."
A provider with genuinely nothing to say still renders nothing; that behaviour
is kept and tested. What changed is that a FAILURE now always carries either an
error or a notice, so it never lands in the empty case.
Scope, stated rather than implied: the tests drive codex, zai, gemini and
copilot, which are the providers a temp home can get past the credential gate.
Cursor needs a real install plus a SQLite state DB holding a JWT. Kiro and
antigravity gate on a binary and a running process, and returning
configured:false when neither exists is correct — nothing broke.
Also pinned: a test asserts limitDisplay.jsx still hides on !configured and
still renders on data.error. Without it every assertion here could be testing a
property the UI no longer has, and would keep passing while the chip regressed.
One methodology correction worth recording: the first probe passed an async
THROWING commandRunner, which is not spawnSync's contract — it returns
{ error, status, stdout }. That produced a crash that looked like a product
defect and was not one. The test uses the real contract.
ci:local exit 0: 932 root tests (+8), 262 dashboard (+4).
This was referenced Jul 25, 2026
Merged
pitimon
added a commit
that referenced
this pull request
Jul 26, 2026
15 commits since v0.39.43. Tracker: #125. User-facing: - The Projects panel honours the date range. It read no query parameter at all, so picking "24h" narrowed every other card while Projects kept showing all-time totals with nothing on screen saying so (#118). - Per-repo cost, with rows written before the change named as unpriced rather than shown as a confident $0 (#121). - Sources with no per-repo attribution are named, instead of their absence reading as "that tool cost nothing here" (#118). - A plan-value card answering what README:32 already promised, labelled as list-price-equivalent rather than a saving (#122). - The Codex quota chip no longer vanishes on a 401 (#119). - `sync --compact`, which reclaimed 34,924 lines to 5,636 on a real install (#117). Behind the scenes: outbound-privacy validator hardening (#111, #114), avatar proxy per-hop revalidation, port-aware allowlisting and a real download cap (#109, #112), a parser conformance ratchet (#116, #120), scripts/graph out of the product gate (#115), and a usage-limits fixture capture tool (#124). Version bumped in all four lockstep locations by the `version` hook: package.json, package-lock.json, both TokenTrackerBar targets, and the Windows csproj. validate:version-lockstep passes. WATCH AFTER SHIPPING: #121 migrates cursors.json on every user's first sync after upgrading, re-keying project buckets from project|source|hour to project|source|model|hour. Without that the old bucket strands and its usage is counted twice. Verified read-only against a real 2,015-bucket state — every key migrated, total_tokens preserved exactly at 6,281,653,062, and all 2,015 queuedKey markers survived, the loss of which would re-append every row. That is one machine; a differently-shaped state is the residual risk. ci:local exit 0: 972 root tests, 302 dashboard. Co-authored-by: itarun.p <itarun.p@somapait.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Addresses Part 1 of #105 — the half the issue itself calls the higher-value one.
Verified first, as the issue asks
#105 says "Needs verifying before fixing", so that is what happened. I drove
getUsageLimitswith a credentialed temp home and a failingfetchImplacross four failure modes (HTTP 500, network unreachable, 401, a body that is not JSON) plus a timeout, and read what each provider actually returned.The suspicion in the issue is not borne out. It records as unknown "whether every fetcher's failure path actually sets
errorrather than returning an unconfigured-looking object". It does — every failure path returns{ configured: true, error: <message> }, andgetUsageLimitswraps each provider in a.catchthat does the same.That is now locked in by tests rather than left as a reading of the source, which is DoD item 2.
One real defect — and it is two issues disagreeing, not a bug
src/lib/usage-limits.js:222turns a 401/403/404 from wham into{ primary_window: null, secondary_window: null }. Its comment says this renders "a neutral empty state instead of a red Fetch failed" — the fix for #52, where users who had not runcodexfor a week saw an alarming error.But there was no neutral state to render.
LimitChipshas exactly two:So on a 401 the chip disappeared — the outcome #105 calls worse than never having had a chip, because the user has been trained to look there and reads absence as "plenty left".
Both issues want the same thing: not red, not gone. The UI just had nowhere to put it.
Added
noticeas the third state — the same subtle grey line as an error, neutral wording, actionable:A provider with genuinely nothing to say still renders nothing; that is kept and tested. What changed is that a failure now always carries either an
erroror anotice, so it never lands in the empty case.Scope, stated rather than implied
The tests drive codex, zai, gemini, copilot — the providers a temp home can get past the credential gate. Cursor needs a real install plus a SQLite state DB holding a JWT. Kiro and antigravity gate on a binary and a running process, and returning
configured: falsewhen neither exists is correct — nothing broke, so there is nothing to say.Test plan
ci:localexit 0 — 932 root tests (+8), 262 dashboard (+4)configured: truewith either an error or a noticeerrorisnulland the notice is actionablelimitDisplay.jsx's own rule — without it every assertion here could be testing a property the UI no longer has, and would keep passing while the chip regressedMethodology correction worth recording
The first probe passed an async throwing
commandRunner, which is notspawnSync's contract — it returns{ error, status, stdout }. That produced a crash that looked like a product defect and was not one. The committed test uses the real contract. Reported here rather than quietly dropped, because "I found a crash" would have been a false finding.Not in this PR
Part 2 — sanitized response fixtures per provider. Those need real responses to sanitize, which I do not have; the issue notes they contain account identifiers and plan details and must be treated as public once committed. Left open.