幂等 Promise.all 与 allSettled 在批量请求失败聚合上的判定差异

批量发起接口请求几乎是每个前端都会遇到的场景:页面初始化时要同时拉用户信息、权限列表、字典数据和消息未读数,导出报表时要按分片并发下载。多数人第一反应是 Promise.all,因为它写得最短。但真正上线后才会发现,Promise.all 的语义是「全有或全无」——任意一个请求 reject,整个结果就变成 reject,那些已经成功返回的数据被直接丢弃,页

批量发起接口请求几乎是每个前端都会遇到的场景:页面初始化时要同时拉用户信息、权限列表、字典数据和消息未读数,导出报表时要按分片并发下载。多数人第一反应是 Promise.all,因为它写得最短。但真正上线后才会发现,Promise.all 的语义是「全有或全无」——任意一个请求 reject,整个结果就变成 reject,那些已经成功返回的数据被直接丢弃,页面既没有数据也没有可用的错误信息,只剩一句 Uncaught (in promise)。而改用 Promise.allSettled 之后,又出现另一个问题:拿到了一个长度对得上的数组,但每个元素长什么样、怎么判断成功、怎么把失败项聚合成用户能看懂的提示,很多同学写错,导致「部分失败」被误判成「全部成功」。

这篇文章讨论的不是「用哪个 API 更好」这种泛泛之谈,而是把两者的判定逻辑拆开:Promise.all 的短路判定发生在什么时候、allSettled 返回值的形状如何影响聚合决策、批量请求在重试失败后如何做幂等归并,以及几个真实报错的定位过程。所有代码都可直接跑。

先对齐语义:短路失败 vs 全量结算

Promise.all(iterable) 返回的 Promise 在以下两种情况下落定:

  • 所有输入 Promise 都 fulfilled,以输入顺序组成的数组 fulfill;
  • 任意一个输入 reject,立即以该 reject 的原因 reject,其余 Promise 的结果被忽略(但它们仍在后台执行,无法取消)。

Promise.allSettled(iterable) 则永不 reject,等所有输入落定后,以 {status, value} 或 {status, reason} 形式的对象数组 fulfill。关键差别就在这里:Promise.all 传到 catch 里的是单个错误,丢失了「哪些成功、哪些失败」的全景;allSettled 不给你判定,只给原始结算明细,判定责任转移给了业务代码。

const req = (name, ms, fail) =>
  new Promise((resolve, reject) =>
    setTimeout(() => (fail ? reject(new Error(`${name} failed`)) : resolve({ name })), ms)
  );

// Promise.all:一个失败,整体失败,成功的值拿不到
Promise.all([req('user', 100, false), req('perm', 50, true), req('dict', 80, false)])
  .then((v) => console.log('all ok', v))
  .catch((e) => console.log('all reject:', e.message)); // all reject: perm failed

// allSettled:全部结算,明细完整
Promise.allSettled([req('user', 100, false), req('perm', 50, true), req('dict', 80, false)])
  .then((rs) => console.log(rs));
// [
//   { status: 'fulfilled', value: { name: 'user' } },
//   { status: 'rejected', reason: Error: perm failed },
//   { status: 'fulfilled', value: { name: 'dict' } }
// ]

注意上面 Promise.all 的输出只有 perm failed,user 和 dict 明明成功了却无从得知。对于「首页多个模块并行加载」这种场景,这种做法等于把可用数据全部陪葬。这就是第一个判定分水岭:失败的传播粒度不同。

allSettled 的结果形状与四类误判

allSettled 的结果数组严格按下标对齐输入顺序,与完成先后无关。这一点是做聚合判定的基础,也是最容易被忽视的地方。很多代码在聚合时犯了下面四类错误。

误判一:用 truthy 判断成功

{ status: 'rejected', reason: ... } 永远是 truthy,所以 if (r) success++ 会把失败算成成功。必须先看 status 字段。

误判二:把空字符串 / 0 / null 当失败值

接口成功但返回 null、''、0 是合法的。判定只能依据 status,不能依据 value 的真假。

误判三:只判 status 不看业务码

很多接口 HTTP 200 但 code: 500,如果封装层没有 reject,allSettled 依然会把它标为 fulfilled。判定链必须是「传输层 status → 业务码 → 数据结构」。正确的做法是在发起请求的封装里就把业务失败转成 reject。

误判四:以为 allSettled 会吞掉未处理的 rejection

它确实不会因为 reject 而整体失败,但失败的 Promise 里抛出的错误如果 attach 了副作用(比如上报、打日志)仍然会执行,而且它的 reason 原样保留。如果这个 reason 是一个巨大的 AxiosError(含 config、request、response 循环引用),直接序列化上报会抛 TypeError: Converting circular structure to JSON,这是线上很常见的报错。

function summarize(results) {
  const ok = [];
  const failed = [];
  results.forEach((r, i) => {
    if (r.status === 'fulfilled') {
      ok.push({ index: i, data: r.value });
    } else {
      // 只保留可序列化字段,避免循环引用
      const reason = r.reason instanceof Error ? r.reason.message : String(r.reason);
      failed.push({ index: i, message: reason });
    }
  });
  return { ok, failed, total: results.length };
}

批量请求的幂等聚合:重试、去重与部分成功落库

真实批量请求一般不是一次性 allSettled 就结束,而是带重试。这里「幂等」有两层含义:同一个请求重复执行结果一致,以及聚合判定对同一批输入是可重复的。写重试时最容易踩的坑是把 allSettled 放在循环外层,导致成功项被反复请求。

async function fetchWithRetry(url, options = {}, { retries = 2, baseDelay = 300 } = {}) {
  let lastErr;
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const res = await fetch(url, options);
      // HTTP 层判定:只把 5xx 与 429 视为可重试
      if (res.status >= 500 || res.status === 429) {
        throw new Error(`HTTP ${res.status}`);
      }
      if (!res.ok) {
        // 4xx 不重试,直接抛出且标记不可重试
        const err = new Error(`HTTP ${res.status}`);
        err.retryable = false;
        throw err;
      }
      return await res.json();
    } catch (err) {
      lastErr = err;
      if (err.retryable === false) break;
      if (attempt < retries) {
        await new Promise((r) => setTimeout(r, baseDelay * 2 ** attempt));
      }
    }
  }
  throw lastErr;
}

把重试收进单个任务的执行体之后,外层聚合就变得干净:allSettled 收到的是「重试耗尽之后」的终态,因此下标的幂等性成立——第 i 项永远对应第 i 个 URL。

async function batchFetch(urls, { concurrency = 4 } = {}) {
  const results = new Array(urls.length);
  let cursor = 0;

  async function worker() {
    while (cursor < urls.length) {
      const i = cursor++;
      try {
        results[i] = { status: 'fulfilled', value: await fetchWithRetry(urls[i]) };
      } catch (err) {
        results[i] = {
          status: 'rejected',
          reason: { url: urls[i], message: err.message }
        };
      }
    }
  }

  await Promise.all(Array.from({ length: Math.min(concurrency, urls.length) }, worker));
  // 手工补齐可能因并发不足未触达的槽位(理论上不会出现,做防御)
  for (let i = 0; i < results.length; i++) {
    if (!results[i]) results[i] = { status: 'rejected', reason: { url: urls[i], message: 'skipped' } };
  }
  return results;
}

这段代码其实是在「自己实现 allSettled + 并发控制」。为什么不用 Promise.allSettled 直接上?因为 allSettled 没有并发上限,几百个 URL 一次性发出会打满浏览器对同域名的连接数(Chrome 对 HTTP/1.1 单域名约 6 个并发),并且服务端限流会大量返回 429。上面用固定数量 worker 从共享游标取任务,既保证了所有下标都会被填充,又天然幂等:同一批 URL 无论调度顺序如何,结果数组和输入一一对应。

聚合判定的策略要按业务选,常见三种:

策略 判定条件 适用场景
全成功才算成功 failed.length === 0 数据一致性要求高,如批量下单、事务型导入
部分成功可接受 ok.length > 0 首页模块、看板卡片,缺一块不影响使用
关键项必成功 关键下标全 fulfilled 先拿 token / 用户态,再拉其余数据

第三种策略用 Promise.all 更合适:把关键请求和其余请求分组,关键组用 all(失败即中止),非关键组用 allSettled(失败可降级)。这是两种 API 混用的正确姿势,而不是二选一。

async function loadDashboard(api) {
  // 关键链路:任何一个失败都中止后续渲染
  const [user, permission] = await Promise.all([api.getUser(), api.getPermission()]);

  // 非关键链路:失败降级为空数据
  const settled = await Promise.allSettled([api.getStats(), api.getNotice(), api.getDict()]);
  const [stats, notice, dict] = settled.map((r) =>
    r.status === 'fulfilled' ? r.value : null
  );

  const failedModules = settled
    .map((r, i) => (r.status === 'rejected' ? ['stats', 'notice', 'dict'][i] : null))
    .filter(Boolean);

  return { user, permission, stats, notice, dict, failedModules };
}

真实报错与定位过程

下面几条是这类代码在线上和本地高频出现的报错,附定位思路。

1. Uncaught (in promise) Error: xxx

出现在 Promise.all 外面没有任何 catch 的时候。即使你给每个请求都写了 .catch,只要有一个漏掉,整体仍然 reject。定位方法:给 Promise.all 的 catch 里打印 err.message,并在构造请求时把 URL 拼进错误消息,例如 new Error(`${url} -> HTTP ${status}`)。不要依赖 err.config.url,降级场景下 config 可能不存在。

2. TypeError: Promise.allSettled is not a function

出现在低版本运行环境。Node 需要 12.9.0 及以上,Chrome 需要 76 及以上,Safari 需要 13 及以上。allSettled 是 ES2020 特性,如果项目 Babel 配置的 targets 太老而又没有引入 core-js 的对应 polyfill,就会在真机上炸。检查 browserslist 与 @babel/preset-env 的 corejs 选项,并在入口引入 import 'core-js/stable'。注意 polyfill 只能用 Promise.allSettled 的第三方实现,因为原生 Promise 不能被替换成自定义类而不影响 instanceof 判断。

3. TypeError: Converting circular structure to JSON

把 reason 直接 JSON.stringify 上报时触发,原因是 AxiosError 的 request/response/config 之间存在循环引用。解决办法是只提取 message、status、url 三个字段,或者使用 JSON.stringify(obj, getCircularReplacer())。

function getCircularReplacer() {
  const seen = new WeakSet();
  return (key, value) => {
    if (typeof value === 'object' && value !== null) {
      if (seen.has(value)) return '[Circular]';
      seen.add(value);
    }
    return value;
  };
}

4. 结果数组长度对不上,业务字段全串位

典型原因是用了 for...of 遍历一个 Promise 数组去做 push,而不是用 map 保序。例如 urls.forEach(async u => arr.push(await f(u))),由于 async 函数完成顺序不确定,arr 顺序随机,且 forEach 不会等待。改用 Promise.allSettled(urls.map(f)) 即可下标对齐。

5. 请求全部成功但判定为部分失败

常见于响应体解码失败。例如接口返回了 gzip 但请求头没有 Accept-Encoding,或返回内容是空串导致 res.json() 抛 Unexpected end of JSON input,此时 status 为 rejected。定位方法是在 fetchWithRetry 里先把 res.text() 打出来,确认响应体非空再解析。对 204 No Content 要显式返回 null,不要直接 json()。

把判定收敛到一个函数

与其在每个业务模块里各写一遍 if-else,不如把判定收敛成纯函数,输入 allSettled 的结果和策略配置,输出结构化的聚合结论。纯函数的好处是可单测、可复现,这对「幂等」要求很重要:同一批输入任何时候调用,结论必须一致。

/**
 * @param {PromiseSettledResult[]} results
 * @param {{ strategy?: 'all'|'any'|'key', keyIndexes?: number[] }} opts
 */
function aggregate(results, opts = {}) {
  const { strategy = 'all', keyIndexes = [] } = opts;
  const ok = [];
  const failed = [];

  results.forEach((r, index) => {
    if (r.status === 'fulfilled') ok.push({ index, value: r.value });
    else failed.push({ index, message: r.reason instanceof Error ? r.reason.message : String(r.reason) });
  });

  const total = results.length;
  const success = ok.length;
  const rate = total === 0 ? 1 : success / total;

  let passed;
  if (strategy === 'any') passed = success > 0;
  else if (strategy === 'key') passed = keyIndexes.every((i) => results[i]?.status === 'fulfilled');
  else passed = failed.length === 0;

  // 失败码归一:同一 message 去重,便于上报
  const failureCodes = [...new Set(failed.map((f) => f.message))];

  return { passed, total, success, rate, ok, failed, failureCodes };
}

// 自测
(async () => {
  const rs = await Promise.allSettled([
    Promise.resolve(1),
    Promise.reject(new Error('HTTP 504')),
    Promise.resolve(3)
  ]);
  console.log(aggregate(rs));                       // passed:false success:2
  console.log(aggregate(rs, { strategy: 'any' }));  // passed:true
  console.log(aggregate(rs, { strategy: 'key', keyIndexes: [0, 2] })); // passed:true
})();

这个函数还有一个附带好处:把 failureCodes 做了去重,避免 500 个分片里有 480 个超时而向监控系统打 480 条同样的告警。告警风暴本身就是批量请求事故的次生灾害,很多团队是在被 PagerDuty 打爆之后才回头补这一层。

另外提醒一点与「幂等」直接相关的实践:批量写操作(批量提交、批量导入)不要用 allSettled 全量并发后不区分重试。写请求重试必须携带幂等键(如 Idempotency-Key 请求头,值为客户端生成的 UUID),否则某个分片超时后重试,服务端可能已经写入成功,导致重复数据。判定「成功」时也不能只看 HTTP 状态:部分网关返回 200 但业务体是 { success: false },聚合函数要么在请求封装层把这种响应 reject 掉,要么在 aggregate 的输入前把它规范化。

常见问题 FAQ

Q1:Promise.all 里某个 Promise reject 后,其他 Promise 会停止执行吗?

不会。all 只是不再等待它们,底层任务(尤其是已经发出的 fetch)无法被取消。要真正取消需要 AbortController:把 controller.signal 传给每个 fetch,在 catch 里统一 controller.abort()。

Q2:allSettled 结果里 rejected 的 reason 一定是 Error 吗?

不一定。Promise.reject('boom') 或 reject({ code: 500 }) 都会原样保留。所以聚合时要做 instanceof Error 判断再取 message,否则 reason.message 是 undefined,日志里全是空字符串。

Q3:为什么 allSettled 全成功后,页面还是空白?

大概率是 fulfilled 的 value 本身是业务失败结构,或者你解构时位置错了。用 results.map(r => r.status) 打印一遍,再逐个检查 r.value 的字段名是否与后端一致。另一个可能是接口返回 200 而数据在 data.data.list 层级,解构成了 data.list,得到 undefined,渲染时又不报错。

Q4:需要限制并发时,能不能直接分片后逐片 await Promise.allSettled?

可以,但片与片之间是串行等待,总耗时是各片之和,比固定 worker 的「有空就补」要慢,且首片失败较早时无法提前推进后续片。分片方式还有个隐蔽问题:某一片超时会导致整片等待最后一个慢请求,尾延迟被放大。推荐用前面 batchFetch 的游标 worker 写法。

Q5:Node 环境里 allSettled 行为一致吗?

语义一致,但需要注意 Node 的未处理 rejection 策略。--unhandled-rejections=strict(Node 15+ 默认 throw)下,如果你在某些分支里创建了 Promise 但没把它放进 allSettled,进程会直接退出。跑批脚本时建议显式挂 process.on('unhandledRejection') 记录日志,而不是让进程崩掉。

Q6:聚合失败后要不要自动重试整批?

不要。整批重试会重复成功项,破坏幂等。正确做法是从 failed 里取出 index,只重试失败下标对应的 URL,重试完成后再用新结果覆盖原数组同位置元素,最后重新调用 aggregate。整个过程保持下标对齐,结论可复现。

小结

Promise.all 与 Promise.allSettled 的差别不在「谁更强大」,而在失败判定的传播粒度:前者把任意单点错误放大成全局错误,适合关键链路和必须全成功的写场景;后者把判定权交给调用方,适合可降级的读场景和需要聚合明细的批量任务。真正需要写好的部分在聚合层:只依据 status 判定、按下标保序、把 reason 规范化成可序列化的字段、对失败码去重、对写请求携带幂等键。把这几件事收敛进一个纯函数 aggregate,再配上一层带并发上限和按需重试的执行器,批量请求的失败聚合才谈得上可控与可复现。