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 { if (ms <= 0 || signal.aborted) return Promise.resolve(); return new Promise((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 }); }); }