Retry transient foreground requests before surfacing errors
This commit is contained in:
@@ -35,7 +35,68 @@
|
||||
}
|
||||
}
|
||||
|
||||
const AsyncGuard = { runExclusive };
|
||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
async function retry(task, { attempts = 3, delay = attempt => attempt === 1 ? 350 : 1000,
|
||||
shouldRetry = () => true, sleep = wait } = {}) {
|
||||
let last;
|
||||
for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) {
|
||||
try { return await task(attempt); }
|
||||
catch (error) {
|
||||
last = error;
|
||||
if (attempt >= attempts || !shouldRetry(error, attempt)) throw error;
|
||||
await sleep(delay(attempt));
|
||||
}
|
||||
}
|
||||
throw last;
|
||||
}
|
||||
|
||||
// Retry only idempotent GETs. Keep the timeout active through JSON parsing so
|
||||
// a stalled response body is treated like a stalled connection.
|
||||
async function getJson(url, options = {}, config = {}) {
|
||||
const fetchFn = config.fetch || root.fetch?.bind(root);
|
||||
if (!fetchFn) throw new Error('fetch is unavailable');
|
||||
if ((options.method || 'GET').toUpperCase() !== 'GET') throw new Error('Retry helper only accepts GET');
|
||||
const attempts = config.attempts ?? 3, timeoutMs = config.timeoutMs ?? 20000;
|
||||
return retry(async () => {
|
||||
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
let timedOut = false, timer, externalAbort;
|
||||
const external = options.signal;
|
||||
if (controller && external) {
|
||||
if (external.aborted) throw external.reason || new DOMException('Aborted', 'AbortError');
|
||||
externalAbort = () => controller.abort(external.reason);
|
||||
external.addEventListener('abort', externalAbort, { once: true });
|
||||
}
|
||||
if (controller && timeoutMs > 0) timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
|
||||
try {
|
||||
const response = await fetchFn(url, { ...options, ...(controller ? { signal: controller.signal } : {}) });
|
||||
if ([408, 425, 429].includes(response.status) || response.status >= 500) {
|
||||
const error = new Error(`HTTP ${response.status}`); error.retryable = true; throw error;
|
||||
}
|
||||
if (!response.ok) {
|
||||
let message = `HTTP ${response.status}`;
|
||||
try { message = (await response.json()).error || message; } catch {}
|
||||
const error = new Error(message); error.retryable = false; throw error;
|
||||
}
|
||||
const value = await response.json();
|
||||
if (config.retryResult?.(value)) {
|
||||
const error = new Error(value.error || value.message || 'Temporary API failure');
|
||||
error.retryable = true;
|
||||
throw error;
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (external?.aborted) throw error;
|
||||
if (timedOut) { const timeout = new Error('Request timed out'); timeout.retryable = true; throw timeout; }
|
||||
if (error.retryable === undefined) error.retryable = true;
|
||||
throw error;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (external && externalAbort) external.removeEventListener('abort', externalAbort);
|
||||
}
|
||||
}, { attempts, shouldRetry: error => error.retryable !== false, sleep: config.sleep || wait });
|
||||
}
|
||||
|
||||
const AsyncGuard = { runExclusive, retry, getJson };
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = AsyncGuard;
|
||||
|
||||
Reference in New Issue
Block a user