133 lines
3.7 KiB
JavaScript
133 lines
3.7 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
}
|