From ae443961855e4ec4364e3315941122d6cc4d6a46 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sun, 17 May 2026 11:12:18 +0200 Subject: [PATCH] feat(auth): add retry with exponential backoff to refreshClaudeOAuthToken Currently a single 5xx or transient network error (ECONNRESET, ECONNREFUSED, ETIMEDOUT) during token refresh kills the session. Adds optional maxRetries (default: 2) and baseDelayMs (default: 500) parameters with exponential backoff. 4xx errors and ClaudeOAuthRefreshError throw immediately without retry. Non-breaking: callers that don't pass retry options get the new resilient behavior by default. --- packages/core/src/auth.ts | 94 ++++++++++++++++++++++++++------------- 1 file changed, 64 insertions(+), 30 deletions(-) diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 44eeb29..3744d99 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -33,39 +33,73 @@ export async function refreshClaudeOAuthToken(input: { refreshToken: string fetchImpl?: typeof fetch now?: () => number + maxRetries?: number + baseDelayMs?: number }): Promise { const fetchImpl = input.fetchImpl ?? fetch - const response = await fetchImpl(TOKEN_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - grant_type: 'refresh_token', - refresh_token: input.refreshToken, - client_id: CLIENT_ID, - }), - }) - - if (!response.ok) { - const body = await response.text().catch(() => '') - throw new ClaudeOAuthRefreshError(response.status, body) - } - - const json = (await response.json()) as { - access_token: string - refresh_token?: string - expires_in: number - } - const refreshedAt = input.now?.() ?? Date.now() - - return { - access: json.access_token, - refresh: json.refresh_token ?? input.refreshToken, - expires: refreshedAt + json.expires_in * 1000, - expiresIn: json.expires_in, + const maxRetries = input.maxRetries ?? 2 + const baseDelayMs = input.baseDelayMs ?? 500 + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + if (attempt > 0) { + const delay = baseDelayMs * 2 ** (attempt - 1) + await new Promise((resolve) => setTimeout(resolve, delay)) + } + + const response = await fetchImpl(TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: input.refreshToken, + client_id: CLIENT_ID, + }), + }) + + if (!response.ok) { + if (response.status >= 500 && attempt < maxRetries) { + await response.body?.cancel() + continue + } + const body = await response.text().catch(() => '') + throw new ClaudeOAuthRefreshError(response.status, body) + } + + const json = (await response.json()) as { + access_token: string + refresh_token?: string + expires_in: number + } + const refreshedAt = input.now?.() ?? Date.now() + + return { + access: json.access_token, + refresh: json.refresh_token ?? input.refreshToken, + expires: refreshedAt + json.expires_in * 1000, + expiresIn: json.expires_in, + } + } catch (error) { + if (error instanceof ClaudeOAuthRefreshError) throw error + const isNetworkError = + error instanceof Error && + (error.message.includes('fetch failed') || + ('code' in error && + ((error as Error & { code: string }).code === 'ECONNRESET' || + (error as Error & { code: string }).code === 'ECONNREFUSED' || + (error as Error & { code: string }).code === 'ETIMEDOUT' || + (error as Error & { code: string }).code === + 'UND_ERR_CONNECT_TIMEOUT'))) + if (attempt < maxRetries && isNetworkError) { + continue + } + throw error + } } + throw new Error('Token refresh exhausted all retries') } export type AuthorizationResult = {