feat: add refresh retry/timeout and tests
This commit is contained in:
132
refresh-client.js
Normal file
132
refresh-client.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user