From 9559b8d09e8db1f29333263793f83197e115bf8f Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 23 May 2026 07:53:26 +0200 Subject: [PATCH 1/7] fix(auth): parse Retry-After header on 429 refresh errors ClaudeOAuthRefreshError now captures the Retry-After header from 429 responses. Supports both seconds and HTTP date formats. buildRefreshOperationError uses it as the backoff duration when available, falling back to exponential backoff otherwise. --- packages/core/src/accounts.ts | 20 +++++++++++----- packages/core/src/auth.ts | 23 +++++++++++++++++- packages/opencode/src/tests/accounts.test.ts | 22 +++++++++++++++++ packages/opencode/src/tests/auth.test.ts | 25 ++++++++++++++++++++ 4 files changed, 83 insertions(+), 7 deletions(-) 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..f8b17e9ee 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) } } @@ -78,7 +95,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/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 67ded76c8..2f94fda26 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -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..1673970e2 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, @@ -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() + }) +}) From abef0876c43f008225a1a114f353310667eb6143 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 23 May 2026 07:53:54 +0200 Subject: [PATCH 2/7] fix(opencode): add request-path backoff guard for OAuth refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip refreshMainAccessToken() entirely when backoff is active on the request path. Previously each request entered the function, checked backoff internally, and threw — generating noisy per-request log entries. Now the guard runs before entering the function, producing a single clean log entry and throw. --- packages/opencode/src/index.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 1c8561e28..808e82dd0 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -927,6 +927,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { storage?.refresh?.mainLastRefreshError?.nextRetryAt, retryCount: storage?.refresh?.mainLastRefreshError?.retryCount, + expiresInMs, }, ) return @@ -1364,6 +1365,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', { From d39a1bfa3c5696ab718b78874878454ae9078b8d Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 23 May 2026 07:54:09 +0200 Subject: [PATCH 3/7] fix(opencode): improve OAuth refresh debug logging - Log new token expiry after successful refresh (was logging stale value) - Log how long ago token expired on request-path refresh - Include expiresInMs in background timer backoff skip log --- packages/opencode/src/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 808e82dd0..004ac4c1f 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -943,8 +943,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', { @@ -1400,6 +1403,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() From 85b784dbf81ee5439cf488d609661ccf3f4fa2c6 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 23 May 2026 08:19:58 +0200 Subject: [PATCH 4/7] fix(auth): use platform.claude.com token endpoint instead of api.anthropic.com MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OAuth token refresh was hitting api.anthropic.com/v1/oauth/token which is the Workload Identity Federation (WIF) endpoint, not the Claude Code OAuth endpoint. This caused 429 rate limits because Claude Code PKCE refresh tokens are not intended for the WIF endpoint. Changed to platform.claude.com/v1/oauth/token which is the dedicated OAuth service that Claude Code binary and other working plugins use. The authorize URL was already platform.claude.com — the token URL was the only inconsistency. --- packages/core/src/constants.ts | 2 +- packages/opencode/src/tests/accounts.test.ts | 4 ++-- packages/opencode/src/tests/auth.test.ts | 2 +- packages/opencode/src/tests/index.test.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) 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/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 2f94fda26..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( diff --git a/packages/opencode/src/tests/auth.test.ts b/packages/opencode/src/tests/auth.test.ts index 1673970e2..00e841af9 100644 --- a/packages/opencode/src/tests/auth.test.ts +++ b/packages/opencode/src/tests/auth.test.ts @@ -176,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') 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') From cfa3687b3f7b6378f90e83972e0e51ed5ca12735 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 23 May 2026 15:42:15 +0200 Subject: [PATCH 5/7] fix(opencode): preserve backoff across token rotations in multi-session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clearStaleMainRefreshError was clearing the backoff whenever the token hash changed (assuming a new token = fresh start). With multiple opencode sessions, process A refreshes (new token hash), process B sees the hash change, clears its backoff, and immediately retries — hitting the rate limit again. Now the backoff is preserved if nextRetryAt is still in the future, regardless of token rotation. --- packages/opencode/src/index.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 004ac4c1f..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( From 2a82c5636e008e151fd4f58757d443380631f002 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 23 May 2026 23:31:57 +0200 Subject: [PATCH 6/7] fix(auth): add User-Agent header to OAuth token refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloudflare's bot detection on platform.claude.com blocks requests without a User-Agent header (Error 1010, returned as 403). This was the root cause of all 429 refresh failures — the 403 was wrapped as a rate_limit_error by the backend. Sends User-Agent: claude-code/{VERSION} matching what Claude Code binary and other working plugins use. --- packages/core/src/auth.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index f8b17e9ee..997ce4411 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -1,5 +1,6 @@ import { AUTHORIZE_URLS, + CLAUDE_CODE_VERSION, CLIENT_ID, CODE_CALLBACK_URL, OAUTH_SCOPES, @@ -81,6 +82,7 @@ export async function refreshClaudeOAuthToken(input: { headers: { 'Content-Type': 'application/json', Accept: 'application/json', + 'User-Agent': `claude-code/${CLAUDE_CODE_VERSION}`, }, body: JSON.stringify({ grant_type: 'refresh_token', From c7f08cfd9275067e12fdf2c8de0d7eb812d8ad0a Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sun, 24 May 2026 06:46:51 +0200 Subject: [PATCH 7/7] fix(auth): use axios User-Agent for token refresh Anthropic's platform.claude.com rate-limits requests with User-Agent 'claude-code/*' but allows 'axios/1.13.6'. This matches the User-Agent used by working plugins (openclaw). --- packages/core/src/auth.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 997ce4411..7c3056b61 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -1,6 +1,5 @@ import { AUTHORIZE_URLS, - CLAUDE_CODE_VERSION, CLIENT_ID, CODE_CALLBACK_URL, OAUTH_SCOPES, @@ -82,7 +81,7 @@ export async function refreshClaudeOAuthToken(input: { headers: { 'Content-Type': 'application/json', Accept: 'application/json', - 'User-Agent': `claude-code/${CLAUDE_CODE_VERSION}`, + 'User-Agent': 'axios/1.13.6', }, body: JSON.stringify({ grant_type: 'refresh_token',