You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
77 lines
2.7 KiB
77 lines
2.7 KiB
import { parseRetryAfter } from '$libs/http';
|
|
import { HTTP_TIMEOUT_SCOPE_TOTAL } from './consts.ts';
|
|
import { HttpAbortError, HttpTimeoutError } from './errors.ts';
|
|
import type { HttpMethod, RetryConfig } from './types.ts';
|
|
export { parseRetryAfter } from '$libs/http';
|
|
|
|
/**
|
|
* Decide whether a failed attempt should be retried. Hierarchy:
|
|
* 1. User signal aborted → never retry.
|
|
* 2. Total-timeout fired → never retry.
|
|
* 3. Custom `shouldRetry` predicate → its return value wins.
|
|
* 4. Attempts exhausted → no retry.
|
|
* 5. Method must be in `methods` allow-list.
|
|
* 6. Either: error is a network-class failure (no response) OR response
|
|
* status is in `statusCodes`.
|
|
*/
|
|
export function shouldRetryRequest(
|
|
method: HttpMethod,
|
|
policy: RetryConfig,
|
|
ctx: { error: unknown; response?: Response; attempt: number }
|
|
): boolean {
|
|
// Hard stops independent of policy.
|
|
if (ctx.error instanceof HttpAbortError) return false;
|
|
if (ctx.error instanceof HttpTimeoutError && ctx.error.scope === HTTP_TIMEOUT_SCOPE_TOTAL)
|
|
return false;
|
|
|
|
if (policy.shouldRetry) return policy.shouldRetry(ctx);
|
|
|
|
if (ctx.attempt > policy.limit) return false;
|
|
if (!policy.methods.includes(method)) return false;
|
|
|
|
if (ctx.response !== undefined) {
|
|
return policy.statusCodes.includes(ctx.response.status);
|
|
}
|
|
// No response → network or per-attempt timeout. Both are retry-able.
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Compute the delay (ms) before attempt `n+1`. `Retry-After` from the failed
|
|
* response trumps the policy; otherwise the policy `delay(attempt)` is used,
|
|
* jittered if requested, then capped at `backoffLimit`.
|
|
*/
|
|
export function computeRetryDelay(
|
|
policy: RetryConfig,
|
|
attempt: number,
|
|
response: Response | undefined,
|
|
now: number = Date.now()
|
|
): number {
|
|
const headerDelay = response !== undefined ? parseRetryAfter(response.headers, now) : undefined;
|
|
const baseDelay = headerDelay !== undefined ? headerDelay : policy.delay(attempt);
|
|
const jittered = policy.jitter ? baseDelay * Math.random() : baseDelay;
|
|
return Math.min(Math.max(0, jittered), policy.backoffLimit);
|
|
}
|
|
|
|
/**
|
|
* Wait `ms`, resolving early if `signal` aborts. Resolves either way — the
|
|
* caller checks `signal.aborted` after to decide whether to continue.
|
|
*/
|
|
export function delayWithSignal(ms: number, signal: AbortSignal): Promise<void> {
|
|
if (ms <= 0 || signal.aborted) return Promise.resolve();
|
|
|
|
return new Promise<void>((resolve) => {
|
|
const id = setTimeout(() => {
|
|
signal.removeEventListener('abort', onAbort);
|
|
resolve();
|
|
}, ms);
|
|
(id as unknown as { unref?: () => void }).unref?.();
|
|
|
|
function onAbort(): void {
|
|
clearTimeout(id);
|
|
resolve();
|
|
}
|
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
});
|
|
}
|