Skip to content
Open
20 changes: 14 additions & 6 deletions packages/core/src/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 23 additions & 1 deletion packages/core/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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',
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
50 changes: 49 additions & 1 deletion packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -927,6 +940,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
storage?.refresh?.mainLastRefreshError?.nextRetryAt,
retryCount:
storage?.refresh?.mainLastRefreshError?.retryCount,
expiresInMs,
},
)
return
Expand All @@ -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', {
Expand Down Expand Up @@ -1364,13 +1381,44 @@ 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',
{
hasAccess: Boolean(auth.access),
expiresInMs: auth.expires
? auth.expires - Date.now()
: undefined,
expiredAgoMs: auth.expires && auth.expires < Date.now()
? Date.now() - auth.expires
: undefined,
},
)
const refreshStart = nowMs()
Expand Down
26 changes: 24 additions & 2 deletions packages/opencode/src/tests/accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
})
})
27 changes: 26 additions & 1 deletion packages/opencode/src/tests/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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()
})
})
2 changes: 1 addition & 1 deletion packages/opencode/src/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading