Repository navigation
fix(rate-limits): only probe agy when the Antigravity meter is shown, and classify its errors #24296
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(rate-limits): only probe agy when the Antigravity meter is shown, and classify its errors #24296
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
| import { classifyAntigravityUsageFailure } from './antigravity-usage-error' | ||
|
|
||
| /** Captured verbatim from agy 1.2.11 when the account had exhausted its weekly pool. */ | ||
| const REAL_RESOURCE_EXHAUSTED = | ||
| 'error: Individual quota reached. Please upgrade your subscription to increase your limits. Resets in 167h51m3s.\n' + | ||
| 'AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota reached. Please upgrade your subscription to increase your limits. Resets in 167h51m3s.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","retryable":true,"error_id":"6baa4ce9-c373-4086-bb0d-fbb8421bacf3-1"}' | ||
|
|
||
| function agyError(fields: Record<string, unknown>): string { | ||
| return `AGY_ERROR: ${JSON.stringify(fields)}` | ||
| } | ||
|
|
||
| describe('classifyAntigravityUsageFailure', () => { | ||
| it('reads the real RESOURCE_EXHAUSTED diagnostic as rate limiting, not as a sign-out', () => { | ||
| const failure = classifyAntigravityUsageFailure(REAL_RESOURCE_EXHAUSTED) | ||
|
|
||
| expect(failure).toEqual({ failureKind: 'rate-limited', signedOut: false }) | ||
| }) | ||
|
|
||
| it.each([ | ||
| ['status', { status: 'UNAUTHENTICATED' }], | ||
| ['http code', { error_code: 401 }] | ||
| ])('reads an unauthenticated %s as a missing sign-in', (_label, fields) => { | ||
| expect(classifyAntigravityUsageFailure(agyError(fields))).toEqual({ | ||
| failureKind: 'missing-credentials', | ||
| signedOut: true | ||
| }) | ||
| }) | ||
|
|
||
| it('separates an unentitled account from a signed-out one', () => { | ||
| // Why this matters: telling a signed-in user to sign in sends them to re-auth that fixes | ||
| // nothing. | ||
| expect(classifyAntigravityUsageFailure(agyError({ status: 'PERMISSION_DENIED' }))).toEqual({ | ||
| failureKind: 'no-subscription', | ||
| signedOut: false | ||
| }) | ||
| }) | ||
|
|
||
| it('reads a 5xx as a server failure', () => { | ||
| expect(classifyAntigravityUsageFailure(agyError({ error_code: 503 }))).toEqual({ | ||
| failureKind: 'server', | ||
| signedOut: false | ||
| }) | ||
| }) | ||
|
|
||
| it.each([ | ||
| 'You are not logged into Antigravity.', | ||
| 'Error: not signed in. Run `agy` to continue.', | ||
| 'please sign in to continue', | ||
| 'AUTHENTICATION REQUIRED' | ||
| ])('falls back to the sentence when no structured error is printed: %s', (output) => { | ||
| // Why a fallback at all: the structured line is the stable signal, but not every path prints | ||
| // one, and a signed-out account must never be reported as an unreadable payload. | ||
| expect(classifyAntigravityUsageFailure(output)).toEqual({ | ||
| failureKind: 'missing-credentials', | ||
| signedOut: true | ||
| }) | ||
| }) | ||
|
|
||
| it('prefers the structured status over a sentence that disagrees with it', () => { | ||
| const output = `not logged in\n${agyError({ status: 'RESOURCE_EXHAUSTED' })}` | ||
|
|
||
| expect(classifyAntigravityUsageFailure(output)).toEqual({ | ||
| failureKind: 'rate-limited', | ||
| signedOut: false | ||
| }) | ||
| }) | ||
|
|
||
| it.each([ | ||
| ['a successful read', '{"status":"SUCCESS","command":{"name":"usage"}}'], | ||
| ['unrelated noise', 'I0926 quota_manager.go:45] doRefreshQuota: starting reload'], | ||
| ['an unparsable structured line', 'AGY_ERROR: {not json'], | ||
| ['an unrecognised status', agyError({ status: 'SOMETHING_NEW' })], | ||
| ['empty output', ''] | ||
| ])('returns null for %s rather than inventing a cause', (_label, output) => { | ||
| expect(classifyAntigravityUsageFailure(output)).toBeNull() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import type { UsageRateLimitFailureKind } from '../../shared/rate-limit-types' | ||
|
|
||
| /** | ||
| * Classifies why the Antigravity CLI could not answer a quota read. | ||
| * | ||
| * Why not a prose match alone: agy prints a structured diagnostic beside the human sentence, and | ||
| * the structure is the stable part. A line like | ||
| * | ||
| * ``` | ||
| * AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota reached…", | ||
| * "status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","retryable":true} | ||
| * ``` | ||
| * | ||
| * survives rewording of the sentence, which a phrase match does not. The sentences are still read, | ||
| * but only as a fallback for builds or paths that print no structured error. | ||
| */ | ||
|
|
||
| const AGY_ERROR_PREFIX = 'AGY_ERROR:' | ||
|
|
||
| /** | ||
| * Sentences agy has been seen to print for a missing session, lower-cased. | ||
| * | ||
| * Kept deliberately short and generic: these are a fallback for when no structured status is | ||
| * present, so matching the stable fragment beats matching a whole sentence that may gain a suffix. | ||
| */ | ||
| const SIGNED_OUT_PHRASES = [ | ||
| 'not logged into antigravity', | ||
| 'not logged in', | ||
| 'not signed in', | ||
| 'please sign in', | ||
| 'please log in', | ||
| 'sign in to antigravity', | ||
| 'no credentials', | ||
| 'authentication required' | ||
| ] as const | ||
|
|
||
| export type AntigravityUsageFailure = { | ||
| failureKind: UsageRateLimitFailureKind | ||
| /** True when the account exists but cannot currently be read, so Orca should not blame sign-in. */ | ||
| signedOut: boolean | ||
| } | ||
|
|
||
| type StructuredAgyError = { status?: unknown; error_code?: unknown } | ||
|
|
||
| function parseStructuredErrors(output: string): StructuredAgyError[] { | ||
| const parsed: StructuredAgyError[] = [] | ||
| for (const line of output.split('\n')) { | ||
| const index = line.indexOf(AGY_ERROR_PREFIX) | ||
| if (index === -1) { | ||
| continue | ||
| } | ||
| const payload = line.slice(index + AGY_ERROR_PREFIX.length).trim() | ||
| if (!payload.startsWith('{')) { | ||
| continue | ||
| } | ||
| try { | ||
| const value: unknown = JSON.parse(payload) | ||
| if (typeof value === 'object' && value !== null) { | ||
| parsed.push(value) | ||
| } | ||
| } catch { | ||
| continue | ||
| } | ||
| } | ||
| return parsed | ||
| } | ||
|
|
||
| function classifyStructured(error: StructuredAgyError): AntigravityUsageFailure | null { | ||
| const status = typeof error.status === 'string' ? error.status.toUpperCase() : '' | ||
| const code = typeof error.error_code === 'number' ? error.error_code : null | ||
| if (status === 'UNAUTHENTICATED' || code === 401) { | ||
| return { failureKind: 'missing-credentials', signedOut: true } | ||
| } | ||
| if (status === 'PERMISSION_DENIED' || code === 403) { | ||
| // The session is real; the account simply is not entitled to what was asked for. | ||
| return { failureKind: 'no-subscription', signedOut: false } | ||
| } | ||
| if (status === 'RESOURCE_EXHAUSTED' || code === 429) { | ||
| return { failureKind: 'rate-limited', signedOut: false } | ||
| } | ||
| if (code !== null && code >= 500) { | ||
| return { failureKind: 'server', signedOut: false } | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| /** | ||
| * Reads agy's combined stdout/stderr for a reason the quota could not be read. | ||
| * | ||
| * Returns null when nothing recognisable is present, which the caller reports as an unreadable | ||
| * payload rather than inventing a cause. | ||
| */ | ||
| export function classifyAntigravityUsageFailure(output: string): AntigravityUsageFailure | null { | ||
| for (const error of parseStructuredErrors(output)) { | ||
| const classified = classifyStructured(error) | ||
| if (classified) { | ||
| return classified | ||
| } | ||
| } | ||
| const lowered = output.toLowerCase() | ||
| if (SIGNED_OUT_PHRASES.some((phrase) => lowered.includes(phrase))) { | ||
|
Comment on lines
+100
to
+101
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Use prose fallback only when no structured diagnostic exists. When a valid diagnostic has an unrecognized status, this code still applies prose matching. For example, Parse the diagnostics once. If diagnostics exist but none classify, return |
||
| return { failureKind: 'missing-credentials', signedOut: true } | ||
| } | ||
| return null | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,13 +9,20 @@ import { | |
| antigravityCommandName | ||
| } from './antigravity-usage-command' | ||
| import { parseAntigravityUsageStdout, stdoutShowsModelTurn } from './antigravity-usage-response' | ||
| import { classifyAntigravityUsageFailure } from './antigravity-usage-error' | ||
|
|
||
| /** | ||
| * Observed verbatim in agy's own log when the keyring holds no session. agy exits 0 and prints this | ||
| * instead of a usage envelope, so the text is the only thing that separates "signed out" from | ||
| * "answered nothing". | ||
| */ | ||
| const NOT_SIGNED_IN_MARKER = 'not logged into antigravity' | ||
| const GENERIC_FAILURE_REASON = | ||
| 'Antigravity usage is not available. The Antigravity CLI could not read this account’s quota.' | ||
|
|
||
| /** Why a table: each kind is a different user action, and a single sentence would fit none of them. */ | ||
| const ANTIGRAVITY_FAILURE_REASONS: Partial<Record<UsageRateLimitFailureKind, string>> = { | ||
| 'rate-limited': | ||
| 'Antigravity usage is not available right now. The Antigravity API is rate-limiting this account.', | ||
| 'no-subscription': | ||
| 'Antigravity usage is not available. This account is signed in but not entitled to Antigravity quota.', | ||
| server: | ||
| 'Antigravity usage is not available right now. The Antigravity API returned a server error.' | ||
| } | ||
|
|
||
| const UNSUPPORTED_USAGE_COMMAND_REASON = | ||
| 'Antigravity usage is not available. This version of the Antigravity CLI answers `/usage` as a prompt instead of a command, so Orca stopped asking rather than spend quota on it. Update `agy` and restart Orca.' | ||
|
|
@@ -147,10 +154,11 @@ export async function fetchAntigravityRateLimits( | |
| } | ||
|
|
||
| const output = `${result.stdout}\n${result.stderr}` | ||
| if (output.toLowerCase().includes(NOT_SIGNED_IN_MARKER)) { | ||
| const failure = classifyAntigravityUsageFailure(output) | ||
| if (failure?.signedOut) { | ||
|
Comment on lines
+157
to
+158
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| return unavailable( | ||
| 'Antigravity usage is not available. Sign in with `agy` to report this account’s quota.', | ||
| 'missing-credentials', | ||
| failure.failureKind, | ||
| now() | ||
| ) | ||
| } | ||
|
|
@@ -162,6 +170,15 @@ export async function fetchAntigravityRateLimits( | |
| usageCommandUnsupported = true | ||
| return unavailable(UNSUPPORTED_USAGE_COMMAND_REASON, 'usage-unavailable', now()) | ||
| } | ||
| if (!reading && failure) { | ||
| // Why these stay 'error' and not 'unavailable': the account is signed in and the read failed | ||
| // for a reason that can clear on its own, so the segment should keep retrying. | ||
| return failed( | ||
| ANTIGRAVITY_FAILURE_REASONS[failure.failureKind] ?? GENERIC_FAILURE_REASON, | ||
| failure.failureKind, | ||
| now() | ||
| ) | ||
| } | ||
| if (!reading) { | ||
| // Why a non-zero exit is reported only here: `runProcess` treats the exit code as data, and agy | ||
| // exits 0 for a signed-out read, so the code only adds detail once the payload is missing. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import type { ProviderRateLimits } from '../../shared/rate-limit-types' | ||
| /** | ||
| * What the provider reports when the user is not showing Antigravity usage at all. | ||
| * | ||
| * Why a snapshot rather than leaving the slot null: the state shape is published to every reader | ||
| * (status bar, Accounts, mobile), and an absent entry reads as "still loading" instead of | ||
| * "switched off". | ||
| */ | ||
| export function antigravityUsageDisabledSnapshot(now: number = Date.now()): ProviderRateLimits { | ||
| return { | ||
| provider: 'antigravity', | ||
| session: null, | ||
| weekly: null, | ||
| updatedAt: now, | ||
| error: null, | ||
| status: 'idle' | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ import { fetchCursorRateLimits } from '../cursor-fetcher' | |
| import { readCursorAuthSession } from '../cursor-auth' | ||
| import { fetchZcodeRateLimits } from '../zcode-usage-fetcher' | ||
| import { fetchAntigravityRateLimits } from '../antigravity-usage-fetcher' | ||
| import { antigravityUsageDisabledSnapshot } from '../antigravity-usage-snapshot' | ||
| import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher' | ||
| import { createHash } from 'node:crypto' | ||
| import { fetchOpenCodeGoUsage } from '../opencode-go-usage-source-selection' | ||
|
|
@@ -129,7 +130,10 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ | |
| ? this.withFetchingStatus(null, 'opencode-go') | ||
| : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'), | ||
| kimi: this.withFetchingStatus(previousState.kimi, 'kimi'), | ||
| antigravity: this.withFetchingStatus(previousState.antigravity, 'antigravity'), | ||
| antigravity: | ||
| (this.antigravityUsageEnabledResolver?.() ?? true) | ||
| ? this.withFetchingStatus(previousState.antigravity, 'antigravity') | ||
| : previousState.antigravity, | ||
| minimax: miniMaxConfigChanged | ||
| ? this.withFetchingStatus(null, 'minimax') | ||
| : this.withFetchingStatus(previousState.minimax, 'minimax'), | ||
|
|
@@ -155,10 +159,17 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ | |
| (reason) => ({ status: 'rejected', reason }) as const | ||
| ) | ||
|
|
||
| // Why its own promise: the Antigravity read spawns `agy` and waits ~2.5 s for the CLI to start | ||
| // its language server and refresh the quota. Inside the awaited tuple that latency would be | ||
| // added to every other provider's cycle. | ||
| const antigravityResultPromise = fetchAntigravityRateLimits({ signal }).then( | ||
| // Why gated: the probe spawns `agy`, which starts the CLI's language server for ~2.5 s. A user | ||
| // who has agy installed but is not showing Antigravity usage should not pay that every cycle. | ||
| // Default-on matches the status bar's own default, so the meter still fills itself in. | ||
| const antigravityUsageEnabled = this.antigravityUsageEnabledResolver?.() ?? true | ||
| // Why its own promise: that ~2.5 s inside the awaited tuple would be added to every other | ||
| // provider's cycle. | ||
| const antigravityResultPromise = ( | ||
| antigravityUsageEnabled | ||
| ? fetchAntigravityRateLimits({ signal }) | ||
| : Promise.resolve(previousState.antigravity ?? antigravityUsageDisabledSnapshot()) | ||
|
Comment on lines
+168
to
+171
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Report When the user hides the meter after a successful refresh, this branch returns the previous snapshot with Preserve the quota fields and The PR objectives require hiding the meter to retain the last reading and report
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The gate stops the Technical details# Switched-off Antigravity keeps the refresh plan in "full" mode
## Affected sites
- `src/main/rate-limits/service/service-full-cycle-preparation.ts:168-171` — gate off resolves to `previousState.antigravity ?? antigravityUsageDisabledSnapshot()`; the previous snapshot is reused unchanged, so `updatedAt` is frozen.
- `src/main/rate-limits/antigravity-usage-snapshot.ts:16` — publishes `status: 'idle'`.
- `src/main/rate-limits/service/service-polling.ts:93-96` — `!limits || status === 'idle' || status === 'fetching'` returns `{ kind: 'full' }`; the `MIN_REFETCH_MS` debounce at lines 97-102 is only reached for `ok`/`unavailable`.
- `src/main/rate-limits/service/service-configuration.ts:96-98` — `focus`/`show`/`restore` call `refreshIfWindowActive()`.
- No test drives `attach` + a window event with `setAntigravityUsageEnabledResolver(() => false)`; `service-antigravity-usage.test.ts` only calls `service.refresh()`, which bypasses the plan.
## Required outcome
- A slot the user has switched off must not force a full refetch on every window activation while healthy providers remain debounced to `MIN_REFETCH_MS`.
## Suggested approach (optional)
- Have `getActiveWindowRefreshPlan` skip providers reported disabled by a resolver, or give the disabled snapshot a terminal status the plan debounces (`unavailable` is debounced but renders as `--`, so check the desired segment appearance), and add a `focus`-driven regression test. |
||
| ).then( | ||
| (value) => ({ status: 'fulfilled', value }) as const, | ||
| (reason) => ({ status: 'rejected', reason }) as const | ||
| ) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This widens sign-out detection from the single observed phrase to eight generic strings;
'no credentials','please log in'and'authentication required'are broad enough to plausibly appear in an unrelated agy log line, and the classifier runs onstdout + stderrbeforeparseAntigravityUsageStdout, so a false positive would blank a valid reading and tell a signed-in user to sign in — the misdirection this PR sets out to remove. Is there a captured transcript showing these fragments occur only on a sign-out path?