失败之后:重试与降级
前面几页的 taskFn 都会成功。真实的 taskFn 不会:网络抖一下、服务端回一个 429、对端迟迟不响应、缓存穿透打到一个没准备好的下游。运行时对这些情况给出三个签名相同的 middleware,retry、withTimeout 与 withFallback 各自是一次
(taskFn) => taskFn 的纯变换,可以任意叠放。它们各自的行为由错误的类型决定,所以本页从错误模型开始,再逐个看这三层,最后看叠起来之后语义会变成什么。
1 · 错误模型与可重试性
@vega/job 定义了五个错误类。CancelledError、TimeoutError、QueueStoppedError 与 QueueFullError 描述的是任务与调度器之间发生了什么,taskFn 的业务逻辑对它们无话可说;TaskError 才是留给 taskFn 自己的结构化错误,带
type、retryable 与 retryAfter 三个字段。retryAfter 的单位是毫秒,而 HTTP 的 Retry-After 响应头以秒计(RFC 9110 §10.2.3),从响应头转过来要先乘 1000。
retryable 是本页的枢纽。task:done 事件的 payload 里带一个同名布尔,由 task-observer.ts 的分类器算出:QueueFullError、QueueStoppedError 与 TimeoutError 一律记 false,TaskError
听自己的字段,其余错误乐观地记 true(lab/facts.test.ts §1)。事件流与 devtools 那一页的面板上,「可重试」标记读的就是它。
警示 · TaskError 的 retryable 默认是 false。new TaskError('connection refused') 不带第二个参数时该字段取 false,retry() 拿到它一次都不重试(lab/facts.test.ts §1)。要让它进重试循环必须显式写
retryable: true,只标 type: 'network' 没有用。
调度器一侧还有一道策略,与队列与并发里的并发上限同属 SchedulerOptions。onError 默认 'continue',一个任务失败不影响后面的;改成 'stop' 后第一个 error 会让调度器进终态 stopped,pending 队列当场被
drain,每个还在排队的任务各拿一个 QueueStoppedError,之后再 enqueue 的任务也立刻拿到同样的错误(lab/facts.test.ts §1)。四个任务顺序提交、第二个失败时,'continue' 收尾于三个 success 加一个 error,'stop' 收尾于一个 success 加三个 error。
停摆这一支上有一处旧演示没跟上实现。example/cases/on-error-policies.ts 把停摆后的任务按 cancelled 分支打印成「skipped」,而实测终态是 error:scheduler.ts 的 rejectIfBlocked 与 drainStopped 都用 task.fail(err) 而不是
task.skip(),注释写明这是有意的,为的是让 state、onTaskError hook 与被 reject 的 promise 说同一个故事。那条打印 skipped 的分支现在跑不到。
onError 策略,观察 task:done 记下的 retryable 与后续任务的去向。2 · 重试与退避
retry(taskFn, options) 在 taskFn 外面套一个循环,retries 默认 2,总尝试次数是 1 + retries。core/scenarios.test.ts §1 锁下三条与时序无关的事实:前两次抛可重试错、retries: 3 时共 3 次尝试后成功,onAttempt 被调用 2 次;抛不可重试的
TaskError 时一次都不重试;每次都失败时尝试次数等于 1 + retries。
循环有四个不进入下一次尝试的出口:CancelledError、TimeoutError、retryable: false 的 TaskError,以及调用方传的 retryable 谓词否掉的错误。前三个写死在代码里,谓词排在它们后面,这一点在 §4 会有后果。
间隔由 delay 决定,可以是一个毫秒数,也可以是 (retryIndex, error) => number。两者都不给时,若抛的是 TaskError 就读它的 retryAfter。exponentialBackoff() 是现成的 delay 工厂,默认 base: 100、factor: 2、cap: 30_000,第
次重试前等
毫秒,即 100、200、400、800(lab/facts.test.ts §2)。这条曲线叫 backoff:故障往往源于下游过载,等得越久越有希望。
它的默认 jitter 是 'full',把算出来的延迟随机压到
区间里。jitter 解决的是同步问题:同一时刻观察到同一次故障的所有 client 会按同一条曲线重试,在同一个瞬间再次压上来,把刚缓过来的服务重新打垮。'full' 把这批 client 摊平在整个窗口上,'equal' 保留一半延迟作下限,'decorrelated' 用上一次的延迟做种子,只能经
exponentialBackoff 取用,因为它有状态。
注 · retry() 是纯 TaskFn → TaskFn 变换,不认识 runtime,也就没有总线可发——onAttempt 的默认值就是 undefined,一次重试在事件流上不留任何痕迹。运行时从头到尾只看见一个 task:一次 task:registered、一次
task:done,中间那几次尝试全在 taskFn 内部。要把重试画进时间轴,只能自己传 onAttempt 并在里面记账(本页 lab 的 retry.store.ts 就是这么做的)。回调收到的 RetryAttemptInfo 里,次数上限那个字段叫 maxRetries(= 传给 retry() 的
retries),不是尝试总数——attempt 是 1-based 的重试序号,初次执行不计入。
retry() 的尝试时间轴,实心条是一次尝试,虚线框是尝试之间的间隔。可调 retries、失败次数、错误的可重试性与退避策略,观察尝试次数与间隔长度。3 · 超时的两个时刻
withTimeout(taskFn, ms) 到时做的第一件事是 abort 一个从 ctx.signal 派生出来的 bridge signal,第二件事是等 taskFn 自己退出,然后才抛 TimeoutError。它不提前 resolve,也没有把并发槽提前还给调度器的办法。时间轴上因此有两个不同的时刻:timeout 触发点,与 taskFn
函数体真正退出的那一点。
协作的 taskFn 让这两点几乎重合。把 ctx.signal 交给 sleep(ms, signal) 或 fetch(url, { signal }) 的 taskFn,在 abort 的那一刻就 reject 了(core/scenarios.test.ts §2)。不看 signal 的 taskFn 则把第二个时刻推到自己跑完为止:同一段测试里,taskFn 耗时 80ms、预算
20ms 时,协作版在 60ms 之前退出,不协作版要到 60ms 之后(同上)。两者抛的错误一模一样,代价的差别全在退出时刻上。
代价具体是什么,取决于并发上限。并发 1 时后面排队的任务要等到 taskFn 退出才起跑,超时预算于是只限制了调用方等多久,没有限制资源被占多久。这是任务与状态机 §3 那条结论在 middleware 层的复现:AbortSignal 只是一个通知,退出与否是 taskFn 的事。
TimeoutError 的 retryable 写死成 false as const,是类上的只读字段而非构造参数。
同一族里还有 withActivityTimeout(taskFn, ms),预算按「距离上一次 ctx.resetTimeout() 有多久」计,适合分块到达的流:只要还有数据进来就不算超时,卡住不动才算。
4 · middleware 的叠放次序
withFallback(primary, fallback, shouldFallback) 在 primary 抛错时改跑 fallback。第三个参数不传时走默认策略,而默认策略拒绝三类错误:CancelledError、TimeoutError,以及 retryable: false 的 TaskError(lab/facts.test.ts §4)。
这条默认策略值得单独记住,因为它把最常见的那个诉求排除在外了。「主接口超时就读本地缓存」在默认配置下不成立:超时抛的 TimeoutError 走的是重新抛出那一支,fallback 根本不会被调用。要让它降级,得显式传一个放行超时的 shouldFallback,此时任务终态是 success,拿到的是 fallback
的返回值(core/scenarios.test.ts §3)。默认策略的立场是:取消与超时是结构性信号,一个说调用方不要了,一个说预算耗尽了,两者都不适合用一份陈旧数据顶上;只有业务失败才是降级的场合。
三层叠起来的写法是 withFallback(withTimeout(retry(taskFn)))。中间两层换个次序,语义就不同:withTimeout(retry(taskFn)) 的预算覆盖整条重试链,retry(withTimeout(taskFn)) 则给每次尝试各配一份预算。第二种写法看起来更精细,实际用处却有限,因为
TimeoutError 不可重试,超时一旦发生,两种次序下 taskFn 都只被调用一次(lab/facts.test.ts §4)。
补一个谓词也救不回来。retryable?: (error: Error) => boolean 这个签名读起来像是能改写整套判据,实际上它在判定链的最后一环:三条硬编码出口——CancelledError、TimeoutError、retryable: false 的 TaskError——依次排在谓词之前
throw,谓词只能否决不能放行。传 retryable: () => true 之后 TimeoutError 照样一次都不重试(lab/facts.test.ts §4)。要做「每次尝试限时且超时也重试」,只能让内层 taskFn 自己捕获超时并翻译成一个 retryable: true 的 TaskError。
建议 · 三层的常见配比是:retry 处理瞬时故障,次数给到 2–3 次并带 jitter;withTimeout 定调用方的等待上限,套在重试链外面;withFallback 只接业务失败,需要它兜住超时时把
shouldFallback 写明白。三者都是纯变换,不依赖运行时,在单元测试里可以脱离 Runtime 直接调。
withFallback、withTimeout 与 retry 三层 middleware的开关与叠放次序。可逐层开关并切换嵌套次序,观察主链的尝试次数与最终是否走到 fallback。失败的处理到此为止。下一页换一个维度看生命期:一组任务如何随着组件一起消失,作用域与 effects。