《互联网产品的客户端架构·端形态与体验基础》11.2 弱网与接口失败兜底

弱网下只有成功与失败两态是不够的,超时、失败、半成功需要不同兜底。本节给出缓存年龄、关键字段缺失与重试预算共同参与的决策函数,解释为什么超时先看缓存、失败先重试,并用注入时钟验证滑动窗口预算与降级内容展示,说明缓存写回与手动重试的风险边界。

成功与失败两态不够

请求层最常见的抽象是 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 次当成行业标准。它们是假设值,要按产品事实调整并写进配置。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查