Skip to content

fix(limits): a 4xx made the Codex chip disappear instead of saying why - #119

Merged
pitimon merged 1 commit into
mainfrom
fix/105-quota-failure-visible
Jul 25, 2026
Merged

fix(limits): a 4xx made the Codex chip disappear instead of saying why#119
pitimon merged 1 commit into
mainfrom
fix/105-quota-failure-visible

Conversation

@pitimon

@pitimon pitimon commented Jul 25, 2026

Copy link
Copy Markdown
Owner

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 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 actually returned.

The suspicion in the issue is not borne out. It records as unknown "whether every fetcher's failure path actually sets error rather than returning an unconfigured-looking object". It does — 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, which is DoD item 2.

One real defect — and it is two issues disagreeing, not a bug

src/lib/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 #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:

if (data.error) { ...status line... }   // visible
...
if (windows.length === 0) return null;  // gone

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 notice as the third state — the 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 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, 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: false when neither exists is correct — nothing broke, so there is nothing to say.

Test plan

  • ci:local exit 0 — 932 root tests (+8), 262 dashboard (+4)
  • Four failure modes × four reachable providers: all stay configured: true with either an error or a notice
  • A timeout is visible, not absent
  • A missing credential still hides the chip — the complement, and the reason this cannot just be "always configured"
  • A 4xx is a notice, not an error: error is null and the notice is actionable
  • Four component tests for the three chip states, including that "no error, no windows, no notice" still renders nothing
  • A test pins limitDisplay.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 regressed

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

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).
@pitimon
pitimon merged commit 89af66b into main Jul 25, 2026
1 check passed
@pitimon
pitimon deleted the fix/105-quota-failure-visible branch July 25, 2026 23:05
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>
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