combinators.js 5.42 KB
/**
 * 高阶组合器 —— 把「一次外部服务调用」包成带重试/降级的同形函数。
 *
 * 本包不认识 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<any>} 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<void>} [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<any>} 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<any>} F
 * @template B
 * @param {F} fn
 * @param {B[]} bindings  非空。
 * @returns {Array<(...args: any[]) => Promise<any>>}
 */
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<void>}
 */
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' })
}