/** * 高阶组合器 —— 把「一次外部服务调用」包成带重试/降级的同形函数。 * * 本包不认识 key、凭据、厂商、审计:候选就是 `async fn`,key 属于调用方闭包。 * 唯一判定契约是结构化错误上的 `retryable === true`(duck-type,LLMError/ImageError/ * TtsError/VideoError 天然满足);判定可经 `isRetryable` 覆盖,缺省不读 message、不判状态码。 * 嵌套顺序即编排语义:`withFallback(fns.map(withRetry))` = 每候选重试耗尽再降级。 */ import { DEFAULT_RETRY_POLICY, shouldRetry, backoffDelayMs } from './retry.js' /** * 缺省重试/降级判定:结构化错误的 retryable 布尔。 * 普通 Error / null / 缺字段一律不可重试——未归一的错误宁可上抛也不重试。 * @param {unknown} err * @returns {boolean} */ export function isRetryableError(err) { return /** @type {{ retryable?: unknown } | null | undefined} */ (err)?.retryable === true } /** * withRetry —— 单函数重试(指数退避;错误带数值 `retryAfterMs` 时优先采用并封顶)。 * 返回与 `fn` 同形的函数,参数原样透传。 * @template {(...args: any[]) => Promise} F * @param {F} fn * @param {object} [cfg] * @param {import('./retry.js').RetryPolicy} [cfg.policy] * @param {(err: unknown) => boolean} [cfg.isRetryable] * @param {AbortSignal} [cfg.signal] 中止后不再发起新 attempt,退避等待中被中止立即抛。 * @param {(event: { attempt: number, error: unknown }) => void} [cfg.observe] 每次失败回调;attempt 从 1 起算。 * @param {(ms: number, signal?: AbortSignal) => Promise} [cfg.sleep] 可注入(测试免等真实退避)。 * @returns {F} */ export function withRetry(fn, { policy = DEFAULT_RETRY_POLICY, isRetryable = isRetryableError, signal, observe, sleep = abortableSleep, } = {}) { if (typeof fn !== 'function') throw new TypeError('withRetry: fn must be a function') return /** @type {F} */ (async (...args) => { let attempt = 0 for (;;) { if (signal?.aborted) throw abortReason(signal) try { return await fn(...args) } catch (err) { attempt += 1 observe?.({ attempt, error: err }) if (!shouldRetry(attempt, isRetryable(err), policy)) throw err await sleep(backoffDelayMs(attempt, policy, retryAfterMsOf(err)), signal) } } }) } /** * withFallback —— 候选按序降级:可重试错误滑到下一候选,不可重试立即上抛, * 链尾无论何因原样上抛(不吞、不替换)。返回与候选同形的函数,参数原样透传给每个被尝试的候选。 * @template {(...args: any[]) => Promise} F * @param {F[]} fns 非空候选数组,索引 0 为主、末位为最后兜底。 * @param {object} [cfg] * @param {(err: unknown) => boolean} [cfg.isRetryable] * @param {(event: { candidateIndex: number, error: unknown }) => void} [cfg.observe] 每个候选失败时回调(含链尾)。 * @returns {F} */ export function withFallback(fns, { isRetryable = isRetryableError, observe } = {}) { if (!Array.isArray(fns) || fns.length < 1) { throw new TypeError('withFallback: fns must be a non-empty array') } for (const fn of fns) { if (typeof fn !== 'function') throw new TypeError('withFallback: every candidate must be a function') } return /** @type {F} */ (async (...args) => { for (let i = 0; ; i++) { try { return await fns[i](...args) } catch (err) { observe?.({ candidateIndex: i, error: err }) if (i === fns.length - 1 || !isRetryable(err)) throw err } } }) } /** * bindEach —— 一个函数 × N 份绑定(key、endpoint、账号……)展开成候选数组, * 绑定作为最后一个参数传入。配合 withFallback 表达「一个函数几把 key」。 * @template {(...args: any[]) => Promise} F * @template B * @param {F} fn * @param {B[]} bindings 非空。 * @returns {Array<(...args: any[]) => Promise>} */ export function bindEach(fn, bindings) { if (typeof fn !== 'function') throw new TypeError('bindEach: fn must be a function') if (!Array.isArray(bindings) || bindings.length < 1) { throw new TypeError('bindEach: bindings must be a non-empty array') } return bindings.map((binding) => (...args) => fn(...args, binding)) } /** * 错误上的显式等待提示(如 429 的 Retry-After、keyrouter 冷却窗口)。 * @param {unknown} err * @returns {number | undefined} */ function retryAfterMsOf(err) { const v = /** @type {{ retryAfterMs?: unknown } | null | undefined} */ (err)?.retryAfterMs return typeof v === 'number' ? v : undefined } /** * 可被 abort 的 sleep。 * @param {number} ms * @param {AbortSignal} [signal] * @returns {Promise} */ function abortableSleep(ms, signal) { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(abortReason(signal)) return } const t = setTimeout(() => { signal?.removeEventListener?.('abort', onAbort) resolve() }, ms) function onAbort() { clearTimeout(t) reject(abortReason(signal)) } signal?.addEventListener?.('abort', onAbort, { once: true }) }) } /** * 中止原因:优先透传 signal.reason,缺省造 AbortError。 * @param {AbortSignal} [signal] * @returns {unknown} */ function abortReason(signal) { return signal?.reason ?? Object.assign(new Error('aborted'), { name: 'AbortError' }) }