import fetch from 'node-fetch'; const DEFAULT_TIMEOUT_MS = 15000; const DEFAULT_MAX_RETRIES = 2; const DEFAULT_RETRY_BASE_MS = 500; const MAX_RETRY_DELAY_MS = 5000; function normalizeNumber(value, fallback) { const parsed = parseInt(value, 10); if (Number.isFinite(parsed) && parsed >= 0) { return parsed; } return fallback; } export function getRefreshConfig() { return { timeoutMs: normalizeNumber(process.env.DROID_REFRESH_TIMEOUT_MS, DEFAULT_TIMEOUT_MS), maxRetries: normalizeNumber(process.env.DROID_REFRESH_RETRIES, DEFAULT_MAX_RETRIES), retryDelayMs: normalizeNumber(process.env.DROID_REFRESH_RETRY_BASE_MS, DEFAULT_RETRY_BASE_MS) }; } function sleep(ms) { if (!ms || ms <= 0) return Promise.resolve(); return new Promise(resolve => setTimeout(resolve, ms)); } function isRetryableError(error) { if (!error) return false; if (error.name === 'AbortError') return true; const retryCodes = new Set(['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EAI_AGAIN', 'ENOTFOUND']); return retryCodes.has(error.code); } function shouldRetryStatus(status) { return status === 429 || status >= 500; } function parseRetryAfterMs(response) { if (!response?.headers?.get) return null; const raw = response.headers.get('retry-after'); if (!raw) return null; const seconds = parseInt(raw, 10); if (Number.isFinite(seconds)) { return Math.max(0, seconds * 1000); } const dateMs = Date.parse(raw); if (!Number.isNaN(dateMs)) { return Math.max(0, dateMs - Date.now()); } return null; } async function fetchWithTimeout(url, options, timeoutMs, fetchImpl) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { return await fetchImpl(url, { ...options, signal: controller.signal }); } finally { clearTimeout(timeoutId); } } function buildError(status, errorText) { const message = `Failed to refresh token: ${status} ${errorText || ''}`.trim(); const error = new Error(message); error.status = status; return error; } export async function requestRefreshToken(options) { const { refreshUrl, refreshToken, clientId, proxyAgentInfo, timeoutMs = DEFAULT_TIMEOUT_MS, maxRetries = DEFAULT_MAX_RETRIES, retryDelayMs = DEFAULT_RETRY_BASE_MS, fetchImpl = fetch } = options; let attempt = 0; while (true) { try { const formData = new URLSearchParams(); formData.append('grant_type', 'refresh_token'); formData.append('refresh_token', refreshToken); formData.append('client_id', clientId); const fetchOptions = { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: formData.toString() }; if (proxyAgentInfo?.agent) { fetchOptions.agent = proxyAgentInfo.agent; } const response = await fetchWithTimeout(refreshUrl, fetchOptions, timeoutMs, fetchImpl); if (!response.ok) { const errorText = await response.text().catch(() => ''); if (shouldRetryStatus(response.status) && attempt < maxRetries) { const retryAfter = parseRetryAfterMs(response); const delay = retryAfter ?? Math.min(retryDelayMs * (2 ** attempt), MAX_RETRY_DELAY_MS); attempt += 1; await sleep(delay); continue; } throw buildError(response.status, errorText); } return await response.json(); } catch (error) { if (isRetryableError(error) && attempt < maxRetries) { const delay = Math.min(retryDelayMs * (2 ** attempt), MAX_RETRY_DELAY_MS); attempt += 1; await sleep(delay); continue; } throw error; } } }