Avoid Claude CLI fallback during rate-limit refresh (#4553)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
9bfceb934f
commit
0f2b918773
|
|
@ -325,6 +325,102 @@ describe('fetchClaudeRateLimits', () => {
|
|||
expect(fetchViaPty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not mask OAuth auth failures 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: 'stale-oauth-token',
|
||||
refreshToken: 'refresh-token',
|
||||
expiresAt: Date.now() - 60_000
|
||||
}
|
||||
})
|
||||
)
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
type: 'authentication_error',
|
||||
message: 'Invalid OAuth token.'
|
||||
}
|
||||
}),
|
||||
{ status: 401 }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'error',
|
||||
error: 'Invalid OAuth token.'
|
||||
})
|
||||
|
||||
expect(fetchViaPty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not start the PTY fallback when disabled for background fetches', async () => {
|
||||
const configDir = '/Users/test/.claude'
|
||||
const authPreparation: ClaudeRuntimeAuthPreparation = {
|
||||
configDir,
|
||||
envPatch: {},
|
||||
stripAuthEnv: false,
|
||||
provenance: 'system'
|
||||
}
|
||||
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'oauth-token',
|
||||
refreshToken: 'refresh-token',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}
|
||||
})
|
||||
)
|
||||
netFetchMock.mockResolvedValueOnce(new Response('temporary failure', { status: 500 }))
|
||||
|
||||
await expect(
|
||||
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
|
||||
).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'error',
|
||||
error: 'OAuth API returned 500'
|
||||
})
|
||||
|
||||
expect(fetchViaPty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not start the PTY fallback for refresh-only credentials when disabled', async () => {
|
||||
const configDir = '/Users/test/.claude'
|
||||
const authPreparation: ClaudeRuntimeAuthPreparation = {
|
||||
configDir,
|
||||
envPatch: {},
|
||||
stripAuthEnv: false,
|
||||
provenance: 'system'
|
||||
}
|
||||
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
refreshToken: 'refresh-token',
|
||||
expiresAt: Date.now() - 60_000
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
|
||||
).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'error',
|
||||
error: 'Claude OAuth access token unavailable'
|
||||
})
|
||||
|
||||
expect(fetchViaPty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not read inactive managed credentials from unowned auth paths', async () => {
|
||||
setPlatform('linux')
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-'))
|
||||
|
|
|
|||
|
|
@ -279,9 +279,14 @@ async function fetchViaOAuth(token: string): Promise<ProviderRateLimits> {
|
|||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function fetchClaudeRateLimits(options?: {
|
||||
export type FetchClaudeRateLimitsOptions = {
|
||||
authPreparation?: ClaudeRuntimeAuthPreparation
|
||||
}): Promise<ProviderRateLimits> {
|
||||
allowPtyFallback?: boolean
|
||||
}
|
||||
|
||||
export async function fetchClaudeRateLimits(
|
||||
options?: FetchClaudeRateLimitsOptions
|
||||
): Promise<ProviderRateLimits> {
|
||||
if (options?.authPreparation?.runtime === 'wsl' && !options.authPreparation.wslLinuxConfigDir) {
|
||||
return {
|
||||
provider: 'claude',
|
||||
|
|
@ -299,13 +304,17 @@ export async function fetchClaudeRateLimits(options?: {
|
|||
try {
|
||||
return await fetchViaOAuth(oauthCredentials.token)
|
||||
} catch (err) {
|
||||
if (err instanceof OAuthUsageError && err.skipPtyFallback) {
|
||||
if (
|
||||
options?.allowPtyFallback === false ||
|
||||
(err instanceof OAuthUsageError && err.skipPtyFallback)
|
||||
) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
return {
|
||||
provider: 'claude',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: withMacTailscaleDnsHint(err.message),
|
||||
error: withMacTailscaleDnsHint(message),
|
||||
status: 'error'
|
||||
}
|
||||
}
|
||||
|
|
@ -318,6 +327,16 @@ export async function fetchClaudeRateLimits(options?: {
|
|||
// whose OAuth credentials exist. This remains a fallback for older Claude
|
||||
// auth shapes and transient OAuth failures.
|
||||
if (oauthCredentials.token || oauthCredentials.hasRefreshableCredentials) {
|
||||
if (options?.allowPtyFallback === false) {
|
||||
return {
|
||||
provider: 'claude',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: 'Claude OAuth access token unavailable',
|
||||
status: 'error'
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await fetchViaPty({ authPreparation: options?.authPreparation })
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ export async function createOAuthUsageError(res: Response): Promise<OAuthUsageEr
|
|||
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
|
||||
// Why: auth/rate-limit responses are already the user-visible usage API
|
||||
// answer. Falling through to /usage can spawn Claude Code needlessly.
|
||||
res.status === 401 || res.status === 403 || res.status === 429
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -298,6 +298,10 @@ describe('RateLimitService', () => {
|
|||
await service.refresh()
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: undefined,
|
||||
allowPtyFallback: false
|
||||
})
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1)
|
||||
|
|
@ -400,7 +404,8 @@ describe('RateLimitService', () => {
|
|||
wslDistro: 'Ubuntu',
|
||||
wslLinuxConfigDir: '/home/jin/.claude',
|
||||
stripAuthEnv: true
|
||||
})
|
||||
}),
|
||||
allowPtyFallback: false
|
||||
})
|
||||
expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
|
@ -459,6 +464,10 @@ describe('RateLimitService', () => {
|
|||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ allowPtyFallback: false })
|
||||
)
|
||||
|
||||
expect(service.getState().inactiveClaudeAccounts).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ accountId: 'wsl-account-1' })])
|
||||
)
|
||||
|
|
|
|||
|
|
@ -799,7 +799,12 @@ export class RateLimitService {
|
|||
? null
|
||||
: this.getMissingWslCodexHomeResult(codexTarget)
|
||||
const [claudeResult, codexResult, geminiResult, opencodeGoResult] = await Promise.allSettled([
|
||||
fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation }),
|
||||
fetchClaudeRateLimits({
|
||||
authPreparation: claudeAuthPreparation,
|
||||
// Why: active quota refreshes run on startup/focus/timers. They must
|
||||
// never spawn hidden Claude Code, which can trigger macOS App Data TCC.
|
||||
allowPtyFallback: false
|
||||
}),
|
||||
missingWslCodexHome ??
|
||||
fetchCodexRateLimits({
|
||||
codexHomePath,
|
||||
|
|
@ -957,7 +962,12 @@ export class RateLimitService {
|
|||
claude: this.withFetchingStatus(previousState.claude, 'claude')
|
||||
})
|
||||
|
||||
const claude = await fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation }).catch(
|
||||
const claude = await fetchClaudeRateLimits({
|
||||
authPreparation: claudeAuthPreparation,
|
||||
// Why: account-change refreshes share the same automatic active quota
|
||||
// surface as startup/timer refreshes, so keep them API-only as well.
|
||||
allowPtyFallback: false
|
||||
}).catch(
|
||||
(err): ProviderRateLimits => ({
|
||||
provider: 'claude',
|
||||
session: null,
|
||||
|
|
|
|||
Loading…
Reference in New Issue