首页 / 文章 / 修复 Node.js 生产环境代码中的 Async/Await 错误处理漏洞

修复 Node.js 生产环境代码中的 Async/Await 错误处理漏洞

了解 JavaScript 和 Node.js 中五种常见的异步/等待错误处理误区,这些误会导致无声故障和竞态条件,同时提供具体的解决方案。

1766 词

上个季度,有一个团队部署的结账系统在问题暴露前的近一个月里,每周两次对同一订单向客户收取双重费用。问题的根本原因并非不稳定的支付网关,而是一个包裹在await调用外的try/catch代码块——它确实按设计功能处理了错误并继续执行后续操作——而更高层级处的重试逻辑则误以为已解决的承诺就等同于成功。

没人会故意引入那样的漏洞。因为 async/await 语法看起来与普通的同步代码相似,开发者们自然会以同样的方式来处理它。但其底层的错误处理机制仍依赖于承诺拒绝、微任务调度以及取消规则,这些并不完全符合 try/catch 的思维模式。即便是有经验的工程师也常常会中招,而且往往发生在已经通过审查的代码中,因为这类漏洞只有在并发或部分故障的情况下才会显现——而这正是单元测试往往会跳过的场景。

以下是在生产环境中的 JavaScript 以及 Node.js 22/24 代码库中常见的五种错误,以及能够在真实流量环境下有效解决的方案。

错误 1:捕获错误后却继续运行

错误的做法:

async function getUserProfile(userId) {
  try {
    const res = await fetch(`/api/users/${userId}`);
    return await res.json();
  } catch (err) {
    console.error('Failed to fetch user', err);
    return null;
  }
}
async function renderDashboard(userId) {
  const profile = await getUserProfile(userId);
  // profile.name throws here if fetch failed — but the stack trace
  // now points at renderDashboard, not at the network call that actually failed
  document.title = `${profile.name}'s Dashboard`;
}

将 fetch 操作包裹在通用的 catch 块中,会将具体的、可追溯的故障(如 /api/users/42 返回的 500 错误)转化为模糊的错误信息(如 profile is null)。等到真正的异常出现时——比如 TypeError: Cannot read properties of null——此时问题已经远离了根本原因,而且没有任何迹象表明之前有过网络请求。在生产环境中,这种差异会导致问题要么在五分钟内快速解决,要么需要花费两小时来翻阅日志。

正确用法:

async function getUserProfile(userId) {
  const res = await fetch(`/api/users/${userId}`);
  if (!res.ok) {
    throw new Error(`Failed to fetch user ${userId}: ${res.status}`, {
      cause: { status: res.status, userId },
    });
  }
  return res.json();
}
async function renderDashboard(userId) {
  try {
    const profile = await getUserProfile(userId);
    document.title = `${profile.name}'s Dashboard`;
  } catch (err) {
    console.error('Dashboard render failed', err, err.cause);
    showErrorBanner('Could not load your profile. Please retry.');
  }
}

最不理解网络请求的函数——即获取数据的函数——在出现异常时应直接抛出错误。至于“失败”具体意味着什么,是显示提示信息、重新发起请求还是使用缓存数据,这应由具备相应恢复策略的函数来决定。使用 Error.cause(于 ES2022 年引入,所有主流浏览器以及 Node.js 16.9 及更高版本均支持)可以保留结构化的上下文信息,而不会将其简化为晦涩的字符串消息。

错误 2:本应使用 Promise.allSettled 却使用了 Promise.all

错误做法:

async function loadDashboardData(userId) {
  const [profile, orders, recommendations] = await Promise.all([
    fetchProfile(userId),
    fetchOrders(userId),
    fetchRecommendations(userId), // a third-party service with a 2% error rate
  ]);
  return { profile, orders, recommendations };
}

Promise.all 被设计为一旦出现失败就会立即终止整个操作:只要有一个承诺被拒绝,整个调用就会失败,即便其他承诺已经成功解决,其结果也会被丢弃。因此,如果 fetchRecommendations 发生超时,用户将无法访问自己的个人资料和订单历史记录,尽管这两个请求实际上都是无错误地完成的。很多针对本来没有问题的代码而提出的“不稳定仪表板”投诉,背后都是这种设计模式导致的。

正确用法:

async function loadDashboardData(userId) {
  const results = await Promise.allSettled([
    fetchProfile(userId),
    fetchOrders(userId),
    fetchRecommendations(userId),
  ]);

const [profile, orders, recommendations] = results.map((r) =>
    r.status === 'fulfilled' ? r.value : null
  );
  results.forEach((r, i) => {
    if (r.status === 'rejected') {
      logNonFatal(['profile', 'orders', 'recommendations'][i], r.reason);
    }
  });
  return { profile, orders, recommendations };
}

一个实用的准则:只有当确实需要执行所有操作且部分结果毫无意义时,才使用 Promise.all,比如构成单个原子事务的三个写入操作。而当各操作相互独立时,即便界面显示不完整也比完全空白要好——实际上,这涵盖了大多数控制面板、数据聚合接口以及批处理任务,此时应使用 Promise.allSettled

错误3:启动异步任务却从不处理其失败情况

存在问题的模式:

function handleClick(event) {
  logAnalyticsEvent(event); // returns a promise, nobody awaits it
  updateUI();
}

此处,logAnalyticsEvent被声明为async类型,这意味着无论是否有人使用其返回值,它都会返回一个承诺对象。由于没有人为该承诺添加.catch处理逻辑,当承诺被拒绝时便无处可去。如果分析服务无法访问,这种拒绝状态就会变成未处理的承诺拒绝——某些浏览器会默默忽略它,但在Node.js中则会直接导致进程崩溃,因为从Node.js 15版本起,未处理的拒绝默认就会终止进程。在请求处理函数中,这就意味着所有访问该路由的用户都会收到500错误,而这一切都是因为有一个后台调用,却没有任何代码在等待它的结果。

更安全的写法:

function handleClick(event) {
  void logAnalyticsEvent(event).catch((err) => {
    logNonFatal('analytics', err);
  });
  updateUI();
}

在调用前加上void,可以向未来的维护人员以及诸如@typescript-eslint/no-floating-promises这样的工具表明,此处跳过await是刻意为之,并非疏忽。不过真正起到防止后台故障演变为前台中断作用的是.catch块。如果你的代码在Node.js上运行,还值得添加一个顶层process.on('unhandledRejection', ...)监听器作为最后一道防线——但应将其视为安全网,而非主要解决方案。它的作用是记录问题并通知相关人员,而非弥补你忘记编写的.catch块。

错误4:让异步调用同时执行而不取消失败的那些调用

存在问题的模式:

async function search(query) {
  const results = await fetch(`/api/search?q=${query}`).then((r) => r.json());
  renderResults(results);
}

searchInput.addEventListener('input', (e) => search(e.target.value));

每次按键都会触发一次新的网络请求。由于响应并不一定按照发送顺序到达,如果对 "reac" 的查询在 "react" 之后完成,过时的结果就会覆盖屏幕上正确的结果。这并非什么罕见的边缘情况——在速度较慢或受到限制的连接环境下,这种情况会频繁发生,也是任何带有实时搜索功能的应用程序中出现“搜索框显示错误结果”故障报告的最常见原因之一。

更安全的版本:

let activeController = null;

async function search(query) {
  activeController?.abort();
  activeController = new AbortController();
  const { signal } = activeController;

try {
    const res = await fetch(`/api/search?q=${query}`, { signal });
    const results = await res.json();
    renderResults(results);
  } catch (err) {
    if (err.name !== 'AbortError') {
      logNonFatal('search', err);
    }
  }
}
searchInput.addEventListener('input', (e) => search(e.target.value));

AbortController 自 Node.js 15 版本起即原生支持,且所有现代的 fetch 实现也都对其予以支持。它将隐式的竞争状态转化为显式且可控制的竞争状态:最新的请求会胜出,因为之前的所有请求都会被主动中止,而非仅仅因为其在时间上恰好占优。在 React、Angular 或 Vue 中,当组件在请求进行到一半时被卸载时,原理也是如此——应在清理阶段取消请求,而非寄希望于其响应永远不会送达。

错误 5:用 finally 代替正确的错误处理机制

存在问题的模式:

async function processOrder(order) {
  let lock;
  try {
    lock = await acquireLock(order.id);
    await chargeCard(order);
    await updateInventory(order);
  } finally {
    releaseLock(lock);
  }
}

乍看之下这似乎是安全的,因为finally块总会执行,应当始终释放锁。但如果acquireLock在分配任何值之前就抛出异常,那么当finally块执行时lock仍为undefined状态。此时调用releaseLock(undefined)要么会引发第二个与原错误无关的异常,掩盖了最初的故障,要么根据锁机制的具体实现而毫无作用——与此同时另一个完全不同的锁则会一直被占用。finally块仅保证其代码块会执行,但并未说明其中的清理逻辑是否适用于所有可能导致该情况的路径。

更安全的版本:

async function processOrder(order) {
  const lock = await acquireLock(order.id); // outside try — nothing to release yet
  try {
    await chargeCard(order);
    await updateInventory(order);
  } finally {
    await releaseLock(lock);
  }
}

只有那些确实已获取锁的操作才应放在触发清理操作的 try 块中。这只是一个小的结构上的改动,但它将那些在必要时刻才执行的清理操作与那些可能在破坏状态而非修复状态的情况下被触发的清理操作区分开来。

总结

Async/await 并没有解决 JavaScript 的错误处理难题——它只是用看似同步代码的语法将这些问题隐藏起来。以下五种习惯可解决由这种伪装方式引发的大多数生产环境问题:抛出定义明确的错误类型而非将其静默处理,利用 Error.cause 保留原始故障信息;当底层操作彼此无关时使用 Promise.allSettled;绝不要让没有 .catch 的承诺被忽视而置之不理;使用 AbortController 取消过时的异步任务,而非依赖网络时间自行解决;同时将 finally 块的作用限制在清理真正获取过的状态上。

这些做法并无特别之处,也不算先进。它们反映的是那种能在真实网络环境下正常运行的异步代码与仅在演示环境中能运行的异步代码之间的差距。

相关阅读

  • Npm供应链攻击:其运作原理及防御Node.js的方法 — 阐述了诸如账户劫持、域名抢注和依赖混淆等Npm供应链攻击的运作方式,同时提供了强化Node.js安装安全的实用措施。
  • 本地Azure Functions开发:解决常见故障点 — 介绍核心工具、语言运行时与Azurite之间需要保持的一致性,以及针对local.settings.json、触发器及调试错误的实际解决方案。
  • 在 Promise.all、Promise.race 与顺序等待之间做选择 — 了解何时使用 Promise.all() 能提升 Node.js API 的性能,为何一旦出现拒绝就会立即失败,以及如何制定决策框架来选择合适的异步模式。
  • 利用 AsyncLocalStorage 关联异步调用中的日志 — 了解 Node.js 的 AsyncLocalStorage 如何在无需手动将上下文传递到每个函数的情况下,跨 await 语句跟踪如 requestId 这样的请求级上下文。