feat: add refresh retry/timeout and tests

This commit is contained in:
empty
2025-12-27 15:07:54 +08:00
parent 5e01993120
commit a18e45ee78
6 changed files with 331 additions and 85 deletions

132
refresh-client.js Normal file
View File

@@ -0,0 +1,132 @@
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;
}
}
}