Fix Claude usage polling against expired metadata (#2441)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
54a2547ff6
commit
489321ac15
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue