成功与失败两态不够
请求层最常见的抽象是 try { data = await fetch() } catch { showError() }。这个抽象在办公室 Wi-Fi 下够用,在弱网下会丢掉三种真实情况:请求超时了但服务端可能已经处理、响应只到了一半、连接失败了但上一份缓存仍然可用。把它们全部塞进 catch,用户只会看到一个“网络错误,请重试”。
兜底要回答的不是“失败了没有”,而是“现在能给用户什么可信的数据”。可信度有三个来源:网络刚刚返回的完整结果、本地未过期的缓存、已经到达但缺字段的半成品。三者的展示方式、提示文案和后续动作都不同。
本节把请求结果扩展成三态,并把决策写成纯函数。所有时间来自注入的时钟,缓存只是普通对象,测试不需要真实网络,也不需要等 60 秒。
三态与两种失败
| 状态 | 判定 | 用户感受 | 默认方向 |
|---|---|---|---|
timeout | 超过等待上限仍未返回 | 一直在等 | 先给缓存,避免继续等 |
failure | 明确失败,带状态码或断网 | 很快看到错误 | 预算内先重试 |
partial | 响应到达但字段不全 | 页面缺一块 | 关键字段齐全就展示,缺则补请求 |
超时和失败的区别不在“有没有成功”,而在“用户已经等了多久”。超时意味着继续等下去的成本很高,所以缓存优先;失败通常很快返回,重试的边际成本低,所以重试优先。这个顺序如果写反,弱网下会出现“明明有缓存却让用户干等重试”的体验。
partial 还要再分一层:缺的是非关键字段,还是关键字段。列表少了一个可选的角标可以接受,商品缺了价格就不能展示。判断标准应该由页面定义,而不是由网络层猜。
兜底决策函数
函数接收结果、缓存、当前时间、策略和剩余重试次数,返回动作、数据可信度、是否展示提示条以及缓存年龄。它不发请求,也不修改状态。
// file: src/error/fallback-policy.ts
// tier: L1
// verify: node --test test/error/fallback-policy.test.ts
export type NetworkOutcome =
| { state: "ok"; elapsedMs: number }
| { state: "timeout"; elapsedMs: number }
| { state: "failure"; elapsedMs: number; status?: number }
| {
state: "partial";
elapsedMs: number;
received: number;
total: number;
/** 缺失且会破坏主任务的字段名 */
missingCritical: string[];
};
export interface CacheSnapshot {
savedAtMs: number;
/** 测试只关心是否可读,真实实现放序列化后的数据 */
value: unknown;
}
export interface FallbackPolicy {
/** 超过这个年龄的缓存不再参与兜底 */
maxStaleMs: number;
/** 单次请求允许的重试次数 */
retryBudget: number;
}
export type FallbackAction =
| "use-network"
| "retry-inline"
| "use-cache"
| "degraded-content"
| "manual-retry";
export type DataTrust = "fresh" | "stale" | "partial";
export interface FallbackDecision {
action: FallbackAction;
trust: DataTrust;
showBanner: boolean;
cacheAgeMs: number | null;
reason: string;
}
function cacheAge(cache: CacheSnapshot | undefined, now: number): number | null {
if (cache === undefined) return null;
return now - cache.savedAtMs;
}
function usableCache(
cache: CacheSnapshot | undefined,
now: number,
policy: FallbackPolicy,
): boolean {
const age = cacheAge(cache, now);
return age !== null && age >= 0 && age <= policy.maxStaleMs;
}
/**
* 把一次请求结果翻译成端上动作。决策不发起请求,只决定下一步,
* 因此可以在测试里用纯数据覆盖超时、失败与半成功三态。
*/
export function decideFallback(
outcome: NetworkOutcome,
cache: CacheSnapshot | undefined,
now: number,
policy: FallbackPolicy,
retriesLeft: number,
): FallbackDecision {
const age = cacheAge(cache, now);
if (outcome.state === "ok") {
return {
action: "use-network",
trust: "fresh",
showBanner: false,
cacheAgeMs: null,
reason: "请求成功,直接使用网络结果",
};
}
if (outcome.state === "partial") {
if (outcome.missingCritical.length === 0) {
return {
action: "degraded-content",
trust: "partial",
showBanner: true,
cacheAgeMs: null,
reason: "关键字段齐全,保留已到达内容并标注非关键字段缺失",
};
}
if (retriesLeft > 0) {
return {
action: "retry-inline",
trust: "partial",
showBanner: true,
cacheAgeMs: null,
reason: `半成功且缺少 ${outcome.missingCritical.join(",")},先用剩余预算补齐`,
};
}
if (usableCache(cache, now, policy)) {
return {
action: "use-cache",
trust: "stale",
showBanner: true,
cacheAgeMs: age,
reason: "关键字段缺失且预算耗尽,回退到上一份完整缓存",
};
}
return {
action: "manual-retry",
trust: "partial",
showBanner: true,
cacheAgeMs: age,
reason: "半成功、无可用缓存且无预算,交给用户手动重试",
};
}
if (outcome.state === "timeout") {
if (usableCache(cache, now, policy)) {
return {
action: "use-cache",
trust: "stale",
showBanner: true,
cacheAgeMs: age,
reason: "超时优先展示缓存,避免用户继续等待",
};
}
if (retriesLeft > 0) {
return {
action: "retry-inline",
trust: "stale",
showBanner: true,
cacheAgeMs: null,
reason: "没有可用缓存,在预算内做一次短重试",
};
}
return {
action: "manual-retry",
trust: "stale",
showBanner: true,
cacheAgeMs: age,
reason: "超时且无可用缓存、无预算,展示手动重试入口",
};
}
if (retriesLeft > 0) {
return {
action: "retry-inline",
trust: "stale",
showBanner: true,
cacheAgeMs: null,
reason: `请求失败(${outcome.status ?? "无状态码"}),预算内先重试`,
};
}
if (usableCache(cache, now, policy)) {
return {
action: "use-cache",
trust: "stale",
showBanner: true,
cacheAgeMs: age,
reason: "重试预算耗尽,使用未过期的缓存兜底",
};
}
return {
action: "manual-retry",
trust: "stale",
showBanner: true,
cacheAgeMs: age,
reason: "请求失败且没有可用兜底,交还用户决定",
};
}
export interface RetryBudgetOptions {
capacity: number;
windowMs: number;
}
/**
* 滑动窗口重试预算。只看注入的时钟,不启动定时器。
* 窗口内的消耗到达容量后,必须等最早一次消耗滑出窗口才恢复。
*/
export class RetryBudget {
private readonly capacity: number;
private readonly windowMs: number;
private spent: number[];
constructor(options: RetryBudgetOptions) {
if (options.capacity < 0) throw new Error("capacity 不能为负数");
if (options.windowMs <= 0) throw new Error("windowMs 必须为正数");
this.capacity = options.capacity;
this.windowMs = options.windowMs;
this.spent = [];
}
available(now: number): number {
this.prune(now);
return Math.max(0, this.capacity - this.spent.length);
}
consume(now: number): boolean {
this.prune(now);
if (this.spent.length >= this.capacity) return false;
this.spent.push(now);
return true;
}
reset(): void {
this.spent = [];
}
private prune(now: number): void {
const cutoff = now - this.windowMs;
this.spent = this.spent.filter((at) => at > cutoff);
}
}
决策顺序可以总结成三句话:成功直接用;超时先缓存;失败先重试。半成功时先看关键字段,齐全就展示降级内容,缺失才进入重试或缓存。
use-cache 必须带 cacheAgeMs 和 showBanner: true。缓存不是“无感兜底”,用户有权知道看到的是旧数据。把年龄显示成“3 分钟前的数据”,比一句含糊的“网络不佳”更有用。
manual-retry 是最后一步,不是第一步。它把决定权交还用户,同时保留已经到达的内容或缓存年龄,让用户知道重试可能改变什么。
重试预算按窗口而不是按次数
只写“最多重试 2 次”不够。用户连续进出页面时,每次进入都重置计数,实际可能重试几十次。预算应该是滑动窗口:一段时间内最多消耗 N 次,消耗完后要等最早一次滑出窗口才恢复。
RetryBudget 用注入的时钟维护消耗时间戳,available 先清理窗口外的记录,consume 在额度足够时记录一次。它不启动定时器,也不依赖 Date.now(),测试可以直接把时间拨到窗口边界。
窗口长度和容量都是案例假设值。本节测试用 2 次 / 1000 毫秒只是为了在几行内验证滑出行为,真实产品可能用 2 次 / 60 秒。关键是这两个数字要写进策略配置,而不是散落在各个请求调用里。
测试与真实输出
测试覆盖成功、超时优先缓存、超时无缓存重试、失败先重试、失败预算耗尽用缓存、半成功关键字段齐全、半成功缺关键字段、过期缓存、滑动窗口释放额度、预算重置。在实验工程根目录执行单文件命令:
node --test test/error/fallback-policy.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 成功结果直接使用网络数据 (1.004583ms)
✔ 超时优先使用未过期缓存,避免用户继续等待 (0.132292ms)
✔ 超时且无缓存时在预算内短重试 (0.064833ms)
✔ 失败时先重试,即使有缓存也把网络当作首选 (0.061375ms)
✔ 失败且预算耗尽时回退到未过期缓存 (0.067875ms)
✔ 半成功但关键字段齐全时保留已到达内容 (0.053042ms)
✔ 半成功且关键字段缺失时先补请求,再考虑缓存 (0.060125ms)
✔ 缓存年龄超过上限后不再参与兜底 (0.043792ms)
✔ 重试预算按滑动窗口释放额度 (0.124708ms)
✔ 重置预算后重新获得全部额度 (0.108792ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 220.135541
同样不要用目录形式运行。Node 26 会把目录当成模块入口,报 MODULE_NOT_FOUND;单文件命令才与上面的输出一致。
一次弱网下单的复盘
某次大促,下单确认页在弱网下大量出现“提交失败”。用户点击后请求超时,页面直接弹错误,用户再点一次,服务端其实已经创建了订单,于是出现重复订单。复盘发现三个问题:
- 超时被当成失败,直接展示错误,没有先查本地是否有待确认的订单状态。
- 重试没有幂等键,第二次点击被服务端当成新订单。
- 没有重试预算,用户连续点击十几次,每次都会发新请求。
修正分成两层。请求层给下单接口加幂等键,超时后不直接判失败,而是进入“结果未知”状态,先查订单状态;展示层在结果未知时展示“处理中”,并提供“查看订单”而不是“重新提交”。缓存在这里不是主角,幂等和状态查询才是,但它和兜底决策共享同一个原则:不要把所有异常都折叠成“失败”。
缓存的信任等级
DataTrust 有三个值:fresh、stale、partial。它们不是装饰字段,而是给 UI 的契约:
| 信任等级 | 来源 | 必须提示 | 允许写回 |
|---|---|---|---|
fresh | 本次完整响应 | 否 | 是 |
stale | 未过期缓存 | 是,带年龄 | 否 |
partial | 半成功响应 | 是,标注缺失 | 仅非关键字段 |
允许写回这一列最容易被忽略。用户看到旧缓存后修改了表单,如果直接写回,会把旧值覆盖到服务端。正确做法是进入冲突解决或重新拉取,而不是把 stale 当成 fresh。
小结
弱网兜底的核心不是多写几个 catch,而是承认超时、失败、半成功是三种不同状态。超时先缓存、失败先重试、半成功先看关键字段,这个顺序决定了用户是“等一会儿”还是“马上有内容”。
重试预算要按滑动窗口管理,缓存要带年龄和信任等级,手动重试是最后手段而不是默认动作。把这些判断写成纯函数,测试就能覆盖真实网络里最难复现的时序。
阅读导航:上一节:11.1 错误边界与白屏治理 · 下一节:11.3 埋点补报与可恢复性 。
常见错误与边界
- 把超时和失败都当成
catch。超时意味着用户已经等了很久,继续重试的体验成本更高。 - 有缓存却先重试。弱网下让用户盯着加载圈,比直接展示带年龄的旧数据更糟。
- 缓存没有年龄。没有年龄的缓存无法判断可信度,也无法向用户解释。
- 半成功直接丢弃。关键字段齐全时,保留已到达内容比整页报错更有用。
- 重试预算按页面生命周期重置。用户反复进出页面会绕过限制,预算必须跨页面共享。
- 缓存过期后仍然使用。过期缓存的写回风险高于展示风险,必须区分读和写。
- 手动重试做成唯一出口。它应该是兜底链的最后一步,不是第一反应。
- 把案例里的 60 秒、2 次当成行业标准。它们是假设值,要按产品事实调整并写进配置。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。