Fix Claude usage polling against expired metadata (#2441)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-20 14:18:04 -04:00 committed by GitHub
parent 54a2547ff6
commit 489321ac15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 138 additions and 43 deletions

View File

@ -214,7 +214,7 @@ describe('fetchClaudeRateLimits', () => {
)
})
it('tries PTY usage when OAuth credentials are expired but refreshable', async () => {
it('tries OAuth usage even when local credential metadata is expired', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
@ -235,12 +235,58 @@ describe('fetchClaudeRateLimits', () => {
await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
provider: 'claude',
status: 'ok',
session: { usedPercent: 56 }
session: { usedPercent: 12 }
})
expect(netFetchMock).not.toHaveBeenCalled()
expect(netFetchMock).toHaveBeenNthCalledWith(
1,
'https://api.anthropic.com/api/oauth/usage',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer expired-oauth-token'
})
})
)
expect(readFileMock).not.toHaveBeenCalled()
expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation })
expect(fetchViaPty).not.toHaveBeenCalled()
})
it('does not mask OAuth usage rate limits with the PTY fallback', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: {},
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
JSON.stringify({
claudeAiOauth: {
accessToken: 'expired-oauth-token',
refreshToken: 'refresh-token',
expiresAt: Date.now() - 60_000
}
})
)
netFetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ error: { type: 'rate_limit_error' } }), { status: 429 })
)
await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
provider: 'claude',
status: 'error',
error: 'Claude usage is rate limited right now.'
})
expect(netFetchMock).toHaveBeenCalledWith(
'https://api.anthropic.com/api/oauth/usage',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer expired-oauth-token'
})
})
)
expect(fetchViaPty).not.toHaveBeenCalled()
})
it('does not read inactive managed credentials from unowned auth paths', async () => {

View File

@ -1,3 +1,6 @@
/* eslint-disable max-lines -- Why: this module keeps Claude credential source
ordering, OAuth usage fetch semantics, and PTY fallback behavior together so
subscription usage state cannot drift across code paths. */
import { readFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import path from 'node:path'
@ -14,6 +17,7 @@ import {
readClaudeManagedAuthFile,
resolveOwnedClaudeManagedAuthPath
} from '../claude-accounts/managed-auth-path'
import { createOAuthUsageError, OAuthUsageError } from './claude-oauth-usage-error'
const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
const OAUTH_BETA_HEADER = 'oauth-2025-04-20'
@ -83,26 +87,36 @@ type OAuthCredentialReadResult = {
}
// Why: factored out so both the active-account Keychain reader and the
// managed-account reader share the same JSON parsing + expiry check.
// managed-account reader share the same JSON parsing + refreshability check.
function parseOAuthCredentialsJson(raw: string): OAuthCredentialReadResult {
try {
const parsed = JSON.parse(raw) as KeychainCredentials
const oauth = parsed?.claudeAiOauth
const token = oauth?.accessToken
if (!token || typeof token !== 'string') {
return { token: null, hasRefreshableCredentials: false }
}
const refreshToken = oauth?.refreshToken
const expiresAt = oauth?.expiresAt
if (typeof expiresAt === 'number' && expiresAt < Date.now()) {
const hasRefreshableCredentials = typeof refreshToken === 'string' && refreshToken.trim() !== ''
if (!token || typeof token !== 'string') {
return {
token: null,
hasRefreshableCredentials: typeof refreshToken === 'string' && refreshToken.trim() !== ''
hasRefreshableCredentials
}
}
return { token, hasRefreshableCredentials: true }
// Why: Claude's local expiresAt metadata is not authoritative for the
// /api/oauth/usage endpoint. Real Claude Code 2.1 credentials have been
// observed authenticating there after expiresAt, so let the server decide.
return {
token,
hasRefreshableCredentials
}
} catch {
return { token: null, hasRefreshableCredentials: false }
return emptyOAuthCredentialReadResult()
}
}
function emptyOAuthCredentialReadResult(): OAuthCredentialReadResult {
return {
token: null,
hasRefreshableCredentials: false
}
}
@ -113,7 +127,7 @@ function parseOAuthCredentialsJson(raw: string): OAuthCredentialReadResult {
*/
async function readFromKeychain(configDir?: string): Promise<OAuthCredentialReadResult> {
if (process.platform !== 'darwin') {
return { token: null, hasRefreshableCredentials: false }
return emptyOAuthCredentialReadResult()
}
if (configDir) {
@ -128,20 +142,14 @@ async function readFromKeychain(configDir?: string): Promise<OAuthCredentialRead
if (legacyCredentials.token) {
return legacyCredentials
}
return {
token: null,
hasRefreshableCredentials:
scopedCredentials.hasRefreshableCredentials || legacyCredentials.hasRefreshableCredentials
}
return scopedCredentials.hasRefreshableCredentials ? scopedCredentials : legacyCredentials
}
try {
const credentials = await readActiveClaudeKeychainCredentials(configDir)
return credentials
? parseOAuthCredentialsJson(credentials)
: { token: null, hasRefreshableCredentials: false }
return credentials ? parseOAuthCredentialsJson(credentials) : emptyOAuthCredentialReadResult()
} catch {
return { token: null, hasRefreshableCredentials: false }
return emptyOAuthCredentialReadResult()
}
}
@ -150,11 +158,9 @@ async function readCredentialsFromStrictKeychain(
): Promise<OAuthCredentialReadResult> {
try {
const credentials = await readActiveClaudeKeychainCredentialsStrict(configDir)
return credentials
? parseOAuthCredentialsJson(credentials)
: { token: null, hasRefreshableCredentials: false }
return credentials ? parseOAuthCredentialsJson(credentials) : emptyOAuthCredentialReadResult()
} catch {
return { token: null, hasRefreshableCredentials: false }
return emptyOAuthCredentialReadResult()
}
}
@ -169,7 +175,7 @@ async function readFromCredentialsFile(configDir?: string): Promise<OAuthCredent
const raw = await readFile(credPath, 'utf-8')
return parseOAuthCredentialsJson(raw)
} catch {
return { token: null, hasRefreshableCredentials: false }
return emptyOAuthCredentialReadResult()
}
}
@ -194,12 +200,11 @@ async function readOAuthCredentials(configDir?: string): Promise<OAuthCredential
if (fromFile.token) {
return fromFile
}
return {
token: null,
hasRefreshableCredentials:
fromKeychain.hasRefreshableCredentials || fromFile.hasRefreshableCredentials
if (fromFile.hasRefreshableCredentials) {
return fromFile
}
return emptyOAuthCredentialReadResult()
}
// ---------------------------------------------------------------------------
@ -276,7 +281,7 @@ async function fetchViaOAuth(token: string): Promise<ProviderRateLimits> {
})
if (!res.ok) {
throw new Error(`OAuth API returned ${res.status}`)
throw await createOAuthUsageError(res)
}
const data = (await res.json()) as OAuthUsageResponse
@ -306,15 +311,25 @@ export async function fetchClaudeRateLimits(options?: {
if (oauthCredentials.token) {
try {
return await fetchViaOAuth(oauthCredentials.token)
} catch {
} catch (err) {
if (err instanceof OAuthUsageError && err.skipPtyFallback) {
return {
provider: 'claude',
session: null,
weekly: null,
updatedAt: Date.now(),
error: err.message,
status: 'error'
}
}
// OAuth API failed — fall through to PTY scraping as a backup
// for subscription users whose token may still be valid for the CLI.
}
}
// Path B: PTY fallback — only for subscription plan users (Max/Pro)
// whose OAuth credentials exist. The CLI can refresh expired OAuth tokens,
// so an expired access token should not be treated like API-key billing.
// whose OAuth credentials exist. This remains a fallback for older Claude
// auth shapes and transient OAuth failures.
if (oauthCredentials.token || oauthCredentials.hasRefreshableCredentials) {
try {
return await fetchViaPty({ authPreparation: options?.authPreparation })

View File

@ -0,0 +1,34 @@
export class OAuthUsageError extends Error {
constructor(
message: string,
readonly status: number,
readonly skipPtyFallback: boolean
) {
super(message)
}
}
export async function createOAuthUsageError(res: Response): Promise<OAuthUsageError> {
return new OAuthUsageError(
await describeOAuthUsageError(res),
res.status,
// Why: 429 is already the user-visible Claude usage API answer.
// Falling through to /usage masks it with Claude 2.1's session-stats UI.
res.status === 429
)
}
async function describeOAuthUsageError(res: Response): Promise<string> {
if (res.status === 429) {
return 'Claude usage is rate limited right now.'
}
try {
const data = (await res.json()) as { error?: { message?: string } }
if (typeof data.error?.message === 'string' && data.error.message.trim()) {
return data.error.message
}
} catch {
// Ignore malformed error bodies and use the status fallback below.
}
return `OAuth API returned ${res.status}`
}

View File

@ -19,12 +19,12 @@ export type InactiveCodexAccountInfo = {
managedHomePath: string
}
// Why: quota state does not need near-real-time polling, and a less aggressive
// default reduces avoidable Claude /usage pressure. We intentionally use a
// slower cadence here rather than polling every 2 minutes.
const DEFAULT_POLL_MS = 5 * 60 * 1000 // 5 minutes
const MIN_REFETCH_MS = 30 * 1000 // 30 seconds — debounce rapid refresh requests
const STALE_THRESHOLD_MS = 10 * 60 * 1000 // 10 minutes — after this, stale data is dropped
// Why: Claude's subscription usage endpoint has a tight request budget. Quota
// state is informational, so prefer keeping a recent snapshot over polling it
// into 429s during long focused Orca sessions.
const DEFAULT_POLL_MS = 15 * 60 * 1000 // 15 minutes
const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts
const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped
const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open
// Why: the internal state only tracks claude and codex. The inactiveClaudeAccounts