diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index c5599dfee..a818c772b 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -572,12 +572,20 @@ export function buildRefreshOperationError(input: { ? (input.previous.retryCount ?? 0) : 0 const retryCount = previousRetryCount + 1 - const delay = isTransientRefreshError(input.error) - ? Math.min( - MAX_REFRESH_RETRY_DELAY_MS, - MIN_REFRESH_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6), - ) - : NON_TRANSIENT_REFRESH_RETRY_DELAY_MS + let delay: number + if ( + input.error instanceof ClaudeOAuthRefreshError && + input.error.retryAfter + ) { + delay = input.error.retryAfter * 1000 + } else if (isTransientRefreshError(input.error)) { + delay = Math.min( + MAX_REFRESH_RETRY_DELAY_MS, + MIN_REFRESH_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6), + ) + } else { + delay = NON_TRANSIENT_REFRESH_RETRY_DELAY_MS + } return { message: formatErrorMessage(input.error), checkedAt: input.now, diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index ce8b00e4c..7c3056b61 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -12,13 +12,30 @@ type CallbackParams = { state: string } +function parseRetryAfterHeader(value: string | undefined | null): number | undefined { + if (!value) return undefined + const seconds = Number(value) + if (Number.isFinite(seconds) && seconds > 0) return Math.ceil(seconds) + const date = Date.parse(value) + if (Number.isFinite(date)) { + const delta = Math.ceil((date - Date.now()) / 1000) + return delta > 0 ? delta : undefined + } + return undefined +} + export class ClaudeOAuthRefreshError extends Error { + /** Parsed Retry-After value in seconds, if the server provided one. */ + public readonly retryAfter: number | undefined + constructor( public readonly status: number, public readonly body: string, + retryAfterHeader?: string | null, ) { super(`Claude OAuth refresh failed: ${status} — ${body}`) this.name = 'ClaudeOAuthRefreshError' + this.retryAfter = parseRetryAfterHeader(retryAfterHeader ?? undefined) } } @@ -64,6 +81,7 @@ export async function refreshClaudeOAuthToken(input: { headers: { 'Content-Type': 'application/json', Accept: 'application/json', + 'User-Agent': 'axios/1.13.6', }, body: JSON.stringify({ grant_type: 'refresh_token', @@ -78,7 +96,11 @@ export async function refreshClaudeOAuthToken(input: { continue } const body = await response.text().catch(() => '') - throw new ClaudeOAuthRefreshError(response.status, body) + throw new ClaudeOAuthRefreshError( + response.status, + body, + response.headers.get('retry-after'), + ) } const json = (await response.json()) as { diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index af0a05dff..f4c6582a1 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -8,7 +8,7 @@ export const AUTHORIZE_URLS = { export const CODE_CALLBACK_URL = 'https://platform.claude.com/oauth/code/callback' -export const TOKEN_URL = 'https://api.anthropic.com/v1/oauth/token' +export const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token' export const OAUTH_SCOPES = [ 'org:create_api_key', diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 1c8561e28..d67a6b32b 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -329,6 +329,19 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { if (!storage?.refresh || !error?.tokenHash) return const tokenHash = hashRefreshToken(refreshToken) if (error.tokenHash === tokenHash) return + // Don't clear backoff if the error is still within its retry window — + // a new token (from another process) doesn't mean the rate limit is gone. + if (error.nextRetryAt && error.nextRetryAt > Date.now()) { + log( + '[refresh] opencode main oauth keeping backoff despite token rotation', + { + nextRetryAt: error.nextRetryAt, + retryCount: error.retryCount, + remainingMs: error.nextRetryAt - Date.now(), + }, + ) + return + } storage.refresh.mainLastRefreshError = undefined await saveAccounts(storage) log( @@ -927,6 +940,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { storage?.refresh?.mainLastRefreshError?.nextRetryAt, retryCount: storage?.refresh?.mainLastRefreshError?.retryCount, + expiresInMs, }, ) return @@ -942,8 +956,11 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { } await refreshMainAccessToken() + const refreshedAuth = await getAuth() log('[refresh] opencode main oauth refreshed in background', { - expires: latestAuth.expires, + newExpiresInMs: refreshedAuth.expires + ? refreshedAuth.expires - Date.now() + : undefined, }) } catch (error) { log('[refresh] opencode main oauth refresh failed', { @@ -1364,6 +1381,34 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { } await clearStaleMainRefreshError(auth.refresh) if (!auth.access || !auth.expires || auth.expires < Date.now()) { + // Check backoff before attempting refresh — avoids noisy + // per-request retries during prolonged rate limits + const refreshStorage = await loadAccounts() + const mainRefreshError = + refreshStorage?.refresh?.mainLastRefreshError + if ( + auth.refresh && + mainRefreshError && + refreshBackoffActive( + mainRefreshError, + auth.refresh, + Date.now(), + ) + ) { + log( + '[refresh] opencode main oauth request skipped backoff', + { + nextRetryAt: mainRefreshError.nextRetryAt, + retryCount: mainRefreshError.retryCount, + expiresInMs: auth.expires + ? auth.expires - Date.now() + : undefined, + }, + ) + throw new Error( + formatRefreshBackoffMessage(mainRefreshError, Date.now()), + ) + } log( '[refresh] opencode main oauth refresh required for request', { @@ -1371,6 +1416,9 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { expiresInMs: auth.expires ? auth.expires - Date.now() : undefined, + expiredAgoMs: auth.expires && auth.expires < Date.now() + ? Date.now() - auth.expires + : undefined, }, ) const refreshStart = nowMs() diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 67ded76c8..1b3d2f705 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -577,7 +577,7 @@ describe('FallbackAccountManager', () => { const fetchImpl = mock( (input: string | URL | Request, init?: RequestInit) => { - expect(String(input)).toBe('https://api.anthropic.com/v1/oauth/token') + expect(String(input)).toBe('https://platform.claude.com/v1/oauth/token') const body = JSON.parse(String(init?.body)) expect(body.refresh_token).toBe('old-refresh') expect(new Headers(init?.headers).get('content-type')).toBe( @@ -641,7 +641,7 @@ describe('FallbackAccountManager', () => { ) } - expect(url).toBe('https://api.anthropic.com/v1/oauth/token') + expect(url).toBe('https://platform.claude.com/v1/oauth/token') const body = JSON.parse(String(init?.body)) expect(body.refresh_token).toBe('refresh-token') expect(new Headers(init?.headers).get('content-type')).toBe( @@ -709,3 +709,25 @@ describe('FallbackAccountManager', () => { ) }) }) + +describe('buildRefreshOperationError', () => { + test('uses Retry-After when available on 429', () => { + const error = new ClaudeOAuthRefreshError(429, 'rate limited', '120') + const result = buildRefreshOperationError({ + error, + now: 1000000, + refreshToken: 'test-token', + }) + expect(result.nextRetryAt).toBe(1000000 + 120_000) + }) + + test('falls back to exponential backoff when no Retry-After', () => { + const error = new ClaudeOAuthRefreshError(429, 'rate limited') + const result = buildRefreshOperationError({ + error, + now: 1000000, + refreshToken: 'test-token', + }) + expect(result.nextRetryAt).toBe(1000000 + 5 * 60_000) + }) +}) diff --git a/packages/opencode/src/tests/auth.test.ts b/packages/opencode/src/tests/auth.test.ts index e7c3acaf8..00e841af9 100644 --- a/packages/opencode/src/tests/auth.test.ts +++ b/packages/opencode/src/tests/auth.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test' import { authorize, CLIENT_ID, + ClaudeOAuthRefreshError, CODE_CALLBACK_URL, exchange, OAUTH_SCOPES, @@ -175,7 +176,7 @@ describe('refreshClaudeOAuthToken', () => { }) as unknown as typeof fetch, }) - expect(capturedUrl).toBe('https://api.anthropic.com/v1/oauth/token') + expect(capturedUrl).toBe('https://platform.claude.com/v1/oauth/token') expect(capturedHeaders?.get('content-type')).toBe('application/json') const body = JSON.parse(capturedBody ?? '{}') expect(body.grant_type).toBe('refresh_token') @@ -252,3 +253,27 @@ describe('refreshClaudeOAuthToken', () => { } }) }) + +describe('ClaudeOAuthRefreshError', () => { + test('captures Retry-After header as seconds', () => { + const error = new ClaudeOAuthRefreshError(429, 'rate limited', '60') + expect(error.retryAfter).toBe(60) + }) + + test('captures Retry-After header as HTTP date', () => { + const futureDate = new Date(Date.now() + 120_000).toUTCString() + const error = new ClaudeOAuthRefreshError(429, 'rate limited', futureDate) + expect(error.retryAfter).toBeGreaterThan(0) + expect(error.retryAfter).toBeLessThanOrEqual(121) + }) + + test('retryAfter is undefined when header missing', () => { + const error = new ClaudeOAuthRefreshError(429, 'rate limited') + expect(error.retryAfter).toBeUndefined() + }) + + test('retryAfter is undefined for non-parseable values', () => { + const error = new ClaudeOAuthRefreshError(429, 'rate limited', 'garbage') + expect(error.retryAfter).toBeUndefined() + }) +}) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index be2a3dec3..d7c1dd405 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -1289,7 +1289,7 @@ describe('auth.loader', () => { // Should have called token endpoint first const tokenCall = fetchCalls.find((c) => c.url.includes('/v1/oauth/token')) expect(tokenCall).toBeDefined() - expect(tokenCall!.url).toBe('https://api.anthropic.com/v1/oauth/token') + expect(tokenCall!.url).toBe('https://platform.claude.com/v1/oauth/token') const tokenBody = JSON.parse(tokenCall!.body!) expect(tokenBody.grant_type).toBe('refresh_token') expect(tokenBody.refresh_token).toBe('old-refresh')