diff --git a/src/plugin.ts b/src/plugin.ts index b3eab45..7801a66 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -20,13 +20,18 @@ import { accessTokenExpired, isOAuthAuth } from './plugin/auth'; import { logDebug, logError } from './plugin/debug'; import { fetchBergetModels } from './plugin/models'; import { createPkceAuthorizeMethod } from './plugin/pkce-flow'; +import { resilientFetch } from './plugin/resilient-fetch'; import { refreshAccessTokenDirect } from './plugin/token'; type FetchInput = Request | string | URL; /** - * Wraps a native fetch call while preserving headers from both Request + * Wraps a resilient fetch call while preserving headers from both Request * objects and init, then injecting (or overwriting) Authorization. + * + * Uses resilientFetch which retries transient network errors (ECONNRESET, + * ETIMEDOUT, socket hang up) and server errors (502/503/504) with + * exponential backoff and jitter. */ async function fetchWithAuth( authToken: string, @@ -38,7 +43,7 @@ async function fetchWithAuth( const headers = new Headers(request.headers); headers.set('Authorization', `Bearer ${authToken}`); - return fetch(request, { headers }); + return resilientFetch(request, { headers }); } /** diff --git a/src/plugin/auth.test.ts b/src/plugin/auth.test.ts index 2c3d124..4574ed8 100644 --- a/src/plugin/auth.test.ts +++ b/src/plugin/auth.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest'; +import type { OAuthAuthDetails } from './types'; + import { ACCESS_TOKEN_EXPIRY_BUFFER_MS } from '../constants'; import { accessTokenExpired } from './auth'; -import type { OAuthAuthDetails } from './types'; describe('accessTokenExpired - Issue #5', () => { // With raw expiry storage, the buffer lives ONLY in the check. diff --git a/src/plugin/models.ts b/src/plugin/models.ts index e7187fd..e869ae3 100644 --- a/src/plugin/models.ts +++ b/src/plugin/models.ts @@ -4,6 +4,7 @@ import { getModelsEndpoint } from '../constants'; import { logDebug, logError } from './debug'; +import { resilientFetch } from './resilient-fetch'; // Response from /v1/models/chat endpoint interface ChatModel { @@ -49,7 +50,7 @@ export async function fetchBergetModels(): Promise> { logDebug('Fetching chat models from Berget API'); try { - const response = await fetch(getModelsEndpoint(), { + const response = await resilientFetch(getModelsEndpoint(), { headers: { 'Content-Type': 'application/json', }, diff --git a/src/plugin/resilient-fetch.test.ts b/src/plugin/resilient-fetch.test.ts new file mode 100644 index 0000000..799477d --- /dev/null +++ b/src/plugin/resilient-fetch.test.ts @@ -0,0 +1,405 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resilientFetch } from './resilient-fetch'; + +function suppressConsole() { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + return { logSpy }; +} + +describe('resilientFetch', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('returns response immediately on success', async () => { + const mockResponse = new Response('OK', { status: 200 }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse)); + + const response = await resilientFetch('https://api.berget.ai/v1/test'); + + expect(response.status).toBe(200); + expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledTimes(1); + }); + + it('retries on ECONNRESET and succeeds', async () => { + suppressConsole(); + let callCount = 0; + const econnreset = Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }); + + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(econnreset); + } + return Promise.resolve(new Response('OK', { status: 200 })); + }), + ); + + const response = await resilientFetch('https://api.berget.ai/v1/test', undefined, { + baseDelayMs: 10, + maxDelayMs: 50, + }); + + expect(response.status).toBe(200); + expect(callCount).toBe(2); + }); + + it('retries on socket hang up and succeeds', async () => { + suppressConsole(); + let callCount = 0; + const socketError = Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' }); + + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(socketError); + } + return Promise.resolve(new Response('OK', { status: 200 })); + }), + ); + + const response = await resilientFetch('https://api.berget.ai/v1/test', undefined, { + baseDelayMs: 10, + maxDelayMs: 50, + }); + + expect(response.status).toBe(200); + expect(callCount).toBe(2); + }); + + it('retries on 503 and succeeds', async () => { + suppressConsole(); + let callCount = 0; + + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve(new Response('Service Unavailable', { status: 503 })); + } + return Promise.resolve(new Response('OK', { status: 200 })); + }), + ); + + const response = await resilientFetch('https://api.berget.ai/v1/test', undefined, { + baseDelayMs: 10, + maxDelayMs: 50, + }); + + expect(response.status).toBe(200); + expect(callCount).toBe(2); + }); + + it('retries on 502 and succeeds', async () => { + suppressConsole(); + let callCount = 0; + + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve(new Response('Bad Gateway', { status: 502 })); + } + return Promise.resolve(new Response('OK', { status: 200 })); + }), + ); + + const response = await resilientFetch('https://api.berget.ai/v1/test', undefined, { + baseDelayMs: 10, + maxDelayMs: 50, + }); + + expect(response.status).toBe(200); + expect(callCount).toBe(2); + }); + + it('retries on 504 and succeeds', async () => { + suppressConsole(); + let callCount = 0; + + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve(new Response('Gateway Timeout', { status: 504 })); + } + return Promise.resolve(new Response('OK', { status: 200 })); + }), + ); + + const response = await resilientFetch('https://api.berget.ai/v1/test', undefined, { + baseDelayMs: 10, + maxDelayMs: 50, + }); + + expect(response.status).toBe(200); + expect(callCount).toBe(2); + }); + + it('retries on 408 and succeeds', async () => { + suppressConsole(); + let callCount = 0; + + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve(new Response('Request Timeout', { status: 408 })); + } + return Promise.resolve(new Response('OK', { status: 200 })); + }), + ); + + const response = await resilientFetch('https://api.berget.ai/v1/test', undefined, { + baseDelayMs: 10, + maxDelayMs: 50, + }); + + expect(response.status).toBe(200); + expect(callCount).toBe(2); + }); + + it('retries on 429 and succeeds', async () => { + suppressConsole(); + let callCount = 0; + + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve(new Response('Too Many Requests', { status: 429 })); + } + return Promise.resolve(new Response('OK', { status: 200 })); + }), + ); + + const response = await resilientFetch('https://api.berget.ai/v1/test', undefined, { + baseDelayMs: 10, + maxDelayMs: 50, + }); + + expect(response.status).toBe(200); + expect(callCount).toBe(2); + }); + + it('does not retry on 400 (client error)', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Bad Request', { status: 400 }))); + + const response = await resilientFetch('https://api.berget.ai/v1/test'); + + expect(response.status).toBe(400); + expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledTimes(1); + }); + + it('does not retry on 401 (unauthorized)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('Unauthorized', { status: 401 })), + ); + + const response = await resilientFetch('https://api.berget.ai/v1/test'); + + expect(response.status).toBe(401); + expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledTimes(1); + }); + + it('does not retry on 404 (not found)', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Not Found', { status: 404 }))); + + const response = await resilientFetch('https://api.berget.ai/v1/test'); + + expect(response.status).toBe(404); + expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledTimes(1); + }); + + it('does not retry on user-initiated abort (AbortError)', async () => { + const abortError = new DOMException('The operation was aborted', 'AbortError'); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(abortError)); + + await expect(resilientFetch('https://api.berget.ai/v1/test')).rejects.toThrow('aborted'); + expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledTimes(1); + }); + + it('gives up after maxRetries on persistent ECONNRESET', async () => { + suppressConsole(); + const econnreset = Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(econnreset)); + + await expect( + resilientFetch('https://api.berget.ai/v1/test', undefined, { + baseDelayMs: 10, + maxDelayMs: 50, + maxRetries: 2, + }), + ).rejects.toThrow('ECONNRESET'); + + // 1 initial + 2 retries = 3 calls + expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledTimes(3); + }); + + it('gives up after maxRetries on persistent 503', async () => { + suppressConsole(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('Service Unavailable', { status: 503 })), + ); + + const response = await resilientFetch('https://api.berget.ai/v1/test', undefined, { + baseDelayMs: 10, + maxDelayMs: 50, + maxRetries: 2, + }); + + expect(response.status).toBe(503); + // 1 initial + 2 retries = 3 calls + expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledTimes(3); + }); + + it('retries on ETIMEDOUT and succeeds', async () => { + suppressConsole(); + let callCount = 0; + const timeoutError = Object.assign(new Error('connect ETIMEDOUT'), { code: 'ETIMEDOUT' }); + + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(timeoutError); + } + return Promise.resolve(new Response('OK', { status: 200 })); + }), + ); + + const response = await resilientFetch('https://api.berget.ai/v1/test', undefined, { + baseDelayMs: 10, + maxDelayMs: 50, + }); + + expect(response.status).toBe(200); + expect(callCount).toBe(2); + }); + + it('does not retry when maxRetries is 0', async () => { + const econnreset = Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(econnreset)); + + await expect( + resilientFetch('https://api.berget.ai/v1/test', undefined, { maxRetries: 0 }), + ).rejects.toThrow('ECONNRESET'); + + expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledTimes(1); + }); + + it('passes request options through correctly', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('OK'))); + + await resilientFetch('https://api.berget.ai/v1/test', { + body: JSON.stringify({ prompt: 'hello' }), + headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' }, + method: 'POST', + }); + + const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0]; + expect(url).toBe('https://api.berget.ai/v1/test'); + expect((init as RequestInit).method).toBe('POST'); + expect((init as RequestInit).body).toBe('{"prompt":"hello"}'); + }); + + it('uses default retry config when options are not provided', async () => { + suppressConsole(); + const econnreset = Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(econnreset)); + + await expect(resilientFetch('https://api.berget.ai/v1/test')).rejects.toThrow('ECONNRESET'); + + // Default maxRetries is 3, so 1 initial + 3 retries = 4 calls + expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledTimes(4); + }); + + it('retries on "fetch failed" TypeError', async () => { + suppressConsole(); + let callCount = 0; + + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(new TypeError('Failed to fetch')); + } + return Promise.resolve(new Response('OK', { status: 200 })); + }), + ); + + const response = await resilientFetch('https://api.berget.ai/v1/test', undefined, { + baseDelayMs: 10, + maxDelayMs: 50, + }); + + expect(response.status).toBe(200); + expect(callCount).toBe(2); + }); + + it('handles ECONNABORTED error code', async () => { + suppressConsole(); + let callCount = 0; + const abortedError = Object.assign(new Error('socket hang up'), { + code: 'ECONNABORTED', + }); + + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(abortedError); + } + return Promise.resolve(new Response('OK', { status: 200 })); + }), + ); + + const response = await resilientFetch('https://api.berget.ai/v1/test', undefined, { + baseDelayMs: 10, + maxDelayMs: 50, + }); + + expect(response.status).toBe(200); + expect(callCount).toBe(2); + }); + + it('returns 200 response without retrying', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('OK', { status: 200 }))); + + const response = await resilientFetch('https://api.berget.ai/v1/test', undefined, { + baseDelayMs: 10, + maxDelayMs: 50, + }); + + expect(response.status).toBe(200); + expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledTimes(1); + }); + + it('returns 201 response without retrying', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Created', { status: 201 }))); + + const response = await resilientFetch('https://api.berget.ai/v1/test', undefined, { + baseDelayMs: 10, + maxDelayMs: 50, + }); + + expect(response.status).toBe(201); + expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/plugin/resilient-fetch.ts b/src/plugin/resilient-fetch.ts new file mode 100644 index 0000000..65a27b0 --- /dev/null +++ b/src/plugin/resilient-fetch.ts @@ -0,0 +1,186 @@ +/** + * Resilient fetch wrapper with retry, timeout, and error classification. + * + * Handles transient network errors (ECONNRESET, ETIMEDOUT, socket hang up, etc.) + * that commonly occur with long-lived connections to AI inference endpoints. + */ + +import { logDebug } from './debug'; + +/** Network error codes that are safe to retry */ +const RETRYABLE_ERROR_CODES = new Set([ + 'EAI_AGAIN', + 'ECONNABORTED', + 'ECONNRESET', + 'ENOTFOUND', + 'EPIPE', + 'ETIMEDOUT', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_SOCKET', +]); + +/** HTTP status codes that are safe to retry */ +const RETRYABLE_STATUS_CODES = new Set([408, 429, 502, 503, 504]); + +/** Default request timeout (30 seconds) */ +const DEFAULT_TIMEOUT_MS = 30_000; + +/** Default retry configuration */ +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_BASE_DELAY_MS = 500; +const DEFAULT_MAX_DELAY_MS = 8000; + +export interface ResilientFetchOptions { + /** Base delay for exponential backoff in ms (default: 500) */ + baseDelayMs?: number; + /** Maximum delay between retries in ms (default: 8000) */ + maxDelayMs?: number; + /** Maximum number of retries (default: 3) */ + maxRetries?: number; + /** Request timeout in ms (default: 30000, 0 to disable) */ + timeoutMs?: number; +} + +/** + * Resilient fetch wrapper with automatic retry on transient network errors. + * + * Retries on: + * - Network errors: ECONNRESET, ETIMEDOUT, socket hang up, etc. + * - HTTP status codes: 408, 429, 502, 503, 504 + * + * Does NOT retry on: + * - User-initiated aborts (AbortError) + * - Client errors (4xx except 408/429) + * - Successful responses (2xx) + * + * @example + * ```ts + * const response = await resilientFetch('https://api.berget.ai/v1/chat', { + * method: 'POST', + * body: JSON.stringify({ messages }), + * headers: { 'Content-Type': 'application/json' }, + * }); + * ``` + */ +export async function resilientFetch( + input: Request | string | URL, + init?: RequestInit, + options: ResilientFetchOptions = {}, +): Promise { + const { + baseDelayMs = DEFAULT_BASE_DELAY_MS, + maxDelayMs = DEFAULT_MAX_DELAY_MS, + maxRetries = DEFAULT_MAX_RETRIES, + timeoutMs = DEFAULT_TIMEOUT_MS, + } = options; + + let lastError: unknown; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const response = await fetchWithTimeout(input, init, timeoutMs); + + if (!isRetryableStatus(response.status) || attempt >= maxRetries) { + return response; + } + + const delay = calculateDelay(attempt, baseDelayMs, maxDelayMs); + logDebug( + `HTTP ${response.status} on attempt ${attempt + 1}/${maxRetries + 1}, retrying in ${delay}ms`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } catch (error) { + lastError = error; + + if (!isRetryableError(error) || attempt >= maxRetries) { + throw error; + } + + const delay = calculateDelay(attempt, baseDelayMs, maxDelayMs); + const errorDetail = error instanceof Error ? error.message : String(error); + logDebug( + `Network error on attempt ${attempt + 1}/${maxRetries + 1}: ${errorDetail}, retrying in ${delay}ms`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw lastError; +} + +/** + * Calculates delay with exponential backoff and jitter. + * Jitter prevents thundering herd when many clients retry simultaneously. + */ +function calculateDelay(attempt: number, baseDelayMs: number, maxDelayMs: number): number { + const exponentialDelay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs); + // Not security-sensitive: jitter is only used to spread retry timing + // eslint-disable-next-line sonarjs/pseudo-random + const jitter = exponentialDelay * (0.5 + Math.random() * 0.5); + return Math.floor(jitter); +} + +/** + * Wraps a fetch call with a timeout via AbortSignal.timeout(). + * If the request takes longer than `timeoutMs`, it is aborted. + */ +async function fetchWithTimeout( + input: Request | string | URL, + init: RequestInit | undefined, + timeoutMs: number, +): Promise { + if (timeoutMs <= 0) { + return fetch(input, init); + } + + const timeoutSignal = AbortSignal.timeout(timeoutMs); + + if (init?.signal) { + const compositeSignal = AbortSignal.any([init.signal, timeoutSignal]); + return fetch(input, { ...init, signal: compositeSignal }); + } + + return fetch(input, { ...init, signal: timeoutSignal }); +} + +/** + * Determines whether an error from fetch is transient and safe to retry. + */ +function isRetryableError(error: unknown): boolean { + if (error instanceof DOMException && error.name === 'AbortError') { + return false; + } + + if (error instanceof TypeError && error.message === 'Failed to fetch') { + return true; + } + + if (error instanceof Error && 'code' in error) { + const code = (error as NodeJS.ErrnoException).code; + if (code && RETRYABLE_ERROR_CODES.has(code)) { + return true; + } + } + + if (error instanceof Error) { + const message = error.message.toLowerCase(); + if ( + message.includes('econnreset') || + message.includes('socket hang up') || + message.includes('etimedout') || + message.includes('network') || + message.includes('fetch failed') + ) { + return true; + } + } + + return false; +} + +/** + * Determines whether an HTTP response status is retryable. + */ +function isRetryableStatus(status: number): boolean { + return RETRYABLE_STATUS_CODES.has(status); +} diff --git a/src/plugin/token.test.ts b/src/plugin/token.test.ts index d730dbd..764016a 100644 --- a/src/plugin/token.test.ts +++ b/src/plugin/token.test.ts @@ -350,8 +350,8 @@ describe('refreshAccessTokenDirect', () => { if (result.success) throw new Error('result should be failure'); expect(result.reason).toBe('Network error: Network failure'); expect(errorSpy).toHaveBeenCalledWith( - 'Failed to refresh Berget access token:', - expect.any(Error), + expect.stringContaining('Failed to refresh Berget access token after retries'), + expect.anything(), ); }); @@ -476,7 +476,8 @@ describe('refreshAccessTokenDirect', () => { expect(result.reason).toBe('Invalid token response from refresh endpoint'); }); - // Issue #7 regression: retry on transient 5xx + // Issue #7 regression: retry on transient 503 + // Now handled by resilientFetch with exponential backoff + jitter it('retries once on 503 and succeeds on second attempt', async () => { let callCount = 0; @@ -512,11 +513,11 @@ describe('refreshAccessTokenDirect', () => { expect(result.success).toBe(true); if (!result.success) throw new Error('result should be success'); expect(result.auth.access).toBe('retried-token'); - // Should have taken at least one retry delay (~500ms) - expect(elapsed).toBeGreaterThanOrEqual(400); + // Should have taken at least one retry delay with jitter (base 500ms, min ~250ms) + expect(elapsed).toBeGreaterThanOrEqual(200); }); - it('gives up after two consecutive 503 failures', async () => { + it('gives up after resilientFetch retries on persistent 503', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ @@ -540,8 +541,9 @@ describe('refreshAccessTokenDirect', () => { expect(result.success).toBe(false); if (result.success) throw new Error('result should be failure'); expect(result.reason).toContain('503'); - // Should have taken two retry delays (~500 + ~1500 = ~2000ms) - expect(elapsed).toBeGreaterThanOrEqual(1500); + // resilientFetch retries with exponential backoff + jitter (base 500ms) + // 3 retries: ~250ms + ~500ms + ~1000ms minimum + expect(elapsed).toBeGreaterThanOrEqual(500); }); it('recovers from invalid_grant when disk has a valid token', async () => { diff --git a/src/plugin/token.ts b/src/plugin/token.ts index 2897748..097b0e5 100644 --- a/src/plugin/token.ts +++ b/src/plugin/token.ts @@ -8,7 +8,8 @@ import type { OAuthAuthDetails, PluginInput, RefreshResult } from './types'; import { getTokenRefreshEndpoint } from '../constants'; import { accessTokenExpired, isOAuthAuth } from './auth'; -import { logDebug } from './debug'; +import { logDebug, logError } from './debug'; +import { resilientFetch } from './resilient-fetch'; // Track in-flight refresh requests to prevent duplicates const refreshInFlight = new Map>(); @@ -85,24 +86,19 @@ function buildRefreshResult( /** * Handles the HTTP error response from the token refresh endpoint. - * Decides whether to retry (5xx), recover from disk (invalid_grant), or fail. + * Decides whether to recover from disk (invalid_grant) or fail. + * + * Note: 5xx retries are handled by resilientFetch at the transport layer. + * This function only handles domain-level errors (invalid_grant, etc.). */ async function handleErrorResponse( response: Response, errorText: string, - attempt: number, + _attempt: number, auth: OAuthAuthDetails, client: PluginInput['client'] | undefined, getAuth: (() => Promise) | undefined, ): Promise { - // Retry transient 5xx errors (up to 2 attempts total) - if (response.status >= 500 && attempt <= 2) { - const delay = attempt === 1 ? 500 : 1500; - logDebug(`Refresh got HTTP ${response.status}, retrying in ${delay}ms...`); - await new Promise((resolve) => setTimeout(resolve, delay)); - return refreshAccessTokenInternal(auth, client, getAuth, attempt + 1); - } - // Handle revoked/invalid refresh token if (response.status === 401 || response.status === 400) { const errorData = parseErrorResponse(errorText); @@ -125,7 +121,7 @@ async function handleErrorResponse( return { reason: `Token refresh failed: HTTP ${response.status}`, success: false }; } - // Other errors - might be temporary + // 5xx errors have already been retried by resilientFetch return { reason: `Token refresh failed: HTTP ${response.status}`, success: false }; } @@ -176,6 +172,12 @@ async function persistRefreshedToken( /** * Internal implementation of token refresh + * + * Uses resilientFetch which retries transient network errors (ECONNRESET, + * ETIMEDOUT, socket hang up) and server errors (502/503/504) with + * exponential backoff and jitter. The attempt parameter is kept for + * backward compatibility with handleErrorResponse but is always 1 now + * since resilientFetch handles transport-level retries. */ async function refreshAccessTokenInternal( auth: OAuthAuthDetails, @@ -186,7 +188,7 @@ async function refreshAccessTokenInternal( logDebug('Refreshing access token'); try { - const response = await fetch(getTokenRefreshEndpoint(), { + const response = await resilientFetch(getTokenRefreshEndpoint(), { body: JSON.stringify({ refresh_token: auth.refresh, }), @@ -206,7 +208,7 @@ async function refreshAccessTokenInternal( return buildRefreshResult(data, auth, client); } catch (error) { const reason = error instanceof Error ? error.message : 'Unknown network error'; - console.error('Failed to refresh Berget access token:', error); + logError('Failed to refresh Berget access token after retries', error); return { reason: `Network error: ${reason}`, success: false }; } }