跳到正文
前端知识库
JavaScript

请求客户端:错误提示归属与持续并发队列

从自动识别 catch 的面试题出发,设计兼容 await 的错误契约与失败后仍能继续工作的请求队列。

约 5 分钟JavaScript · Promise · 请求 · 并发 · 错误处理 · 面试

请求层负责传递结果,调用层决定如何恢复业务。先把失败、提示和队列释放的契约说清楚,再实现限流。

1. 能否自动判断调用方处理了错误

题目要求“调用方写了 catch 就局部处理,否则由请求层统一提示”。只观察 .catch 属性无法可靠实现这个契约:

调用方式 代理 catch 的误判
const handler = p.catch 读取属性不等于注册处理器
p.then(onSuccess, onError) 失败处理器是 then 的第二个参数
try { await p } catch (error) { ... } await 不会调用公开的 catch 属性;原生 Promise 还可能绕过公开 then
const a = p.then(...); const b = p.then(...) 分支独立,一个分支 catch 不代表另一个已处理
p.catch(error => { throw error }) 注册过 catch 仍可能继续传播失败
稍后才注册 catch 请求失败当下无法预知未来的消费者行为

Proxy 还必须保留 Promise 原生方法的接收者、两个 then 参数、Symbol/普通属性以及返回链的语义;把所有属性都包装为单参数函数会破坏协议。绑定原 Promise 只能修复部分内部槽问题,不能解决“处理过错误”的语义判断。

浏览器 unhandledrejection / rejectionhandled 可用于兜底观测,表示宿主在特定检查时机发现的未处理拒绝及后来补上的处理器;它们不证明业务恢复成功,也不是请求级提示策略。请求库内部若提前 catch 吞错,宿主甚至看不到原本应暴露的问题。

2. 明确错误提示契约

用显式选项 errorMode: 'local' | 'global' 决定谁展示用户提示,失败始终拒绝返回的原生 Promise。这样 .catch、then(_, onError) 和 try/await/catch 都能正常工作。

  • local:请求层不展示提示,由调用方决定表单字段错误、降级或重试。
  • global:请求层展示统一提示,调用方仍需结束 loading、回滚乐观状态或记录失败;避免再展示同一条提示。
  • 取消:按产品契约单独分类,通常不显示“服务异常”;不能把所有失败都吞成 undefined。

3. 持续接收任务的队列

已有 受限并发专题 处理一批已知任务并返回结果数组。本题的差异是持续调用 request(),每次都立即获得自己的 Promise;某个请求失败后,其他排队请求继续执行。

下面是最小的动态 FIFO 队列。工厂拿到槽位才调用,所有成功/失败路径都在 finally 释放槽位。它限制的是同时在途任务数,不是每秒请求数;时间窗口限速需要另外的令牌桶或服务端配额。

function createTaskQueue(limit) {
  if (!Number.isInteger(limit) || limit < 1) {
    throw new RangeError('limit must be a positive integer');
  }
  const queue = [];
  let head = 0;
  let active = 0;

  function drain() {
    while (active < limit && head < queue.length) {
      const entry = queue[head];
      queue[head++] = undefined; // 释放已启动任务的闭包引用
      active += 1;
      void execute(entry);
    }
    if (head === queue.length) {
      queue.length = 0;
      head = 0;
    }
  }

  async function execute({ task, resolve, reject }) {
    try {
      // 统一接收普通值、Promise、thenable,也捕获工厂同步 throw。
      const value = await Promise.resolve().then(task);
      resolve(value);
    } catch (error) {
      reject(error);
    } finally {
      active -= 1;
      drain();
    }
  }

  return function schedule(task) {
    if (typeof task !== 'function') {
      return Promise.reject(new TypeError('task must be a function'));
    }
    return new Promise((resolve, reject) => {
      queue.push({ task, resolve, reject });
      drain();
    });
  };
}

不变量是 0 <= active <= limit、每个条目只启动一次、每个完成条目只释放一个槽位。头游标避免反复 shift() 搬移元素,全部出队后清空数组;长期持续积压时还应定期压缩已消费前缀或使用环形队列。生产实现必须有排队上限、取消/超时与排队耗时指标,不能允许无界队列耗尽内存。

4. 组合成请求客户端

transport 负责网络协议和错误归一化,schedule 负责并发,reportError 负责提示。通知失败不能替换原始请求错误;通知不占网络并发槽,但调用方的失败通知会等待本例的报告过程,真实系统应保证报告有界或让提示函数同步入队。

function createRequestClient({ transport, schedule, reportError }) {
  return async function request(url, { errorMode = 'local', signal } = {}) {
    if (errorMode !== 'local' && errorMode !== 'global') {
      throw new TypeError('unknown errorMode');
    }
    try {
      return await schedule(() => {
        signal?.throwIfAborted(); // 排队期间已取消时,不再启动网络请求
        return transport(url, { signal });
      });
    } catch (error) {
      if (errorMode === 'global' && !signal?.aborted) {
        try {
          await reportError(error);
        } catch {
          // 提示失败仍保留原请求错误;可用独立的非抛错日志记录通知故障。
        }
      }
      throw error;
    }
  };
}

const request = createRequestClient({
  schedule: createTaskQueue(3),
  transport: async (url, { signal }) => {
    const response = await fetch(url, { signal });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json(); // 解析失败也会 reject
  },
  reportError: error => console.error('统一提示:', error.message)
});

async function loadProfile() {
  try {
    return await request('/api/profile', { errorMode: 'global' });
  } catch {
    // 请求层已负责提示;这里返回明确的业务降级值。
    return null;
  }
}

示例约定 transport 遵守 signal;若底层忽略取消、返回永不完成的 Promise,槽位仍无法释放,简单 Promise.race 超时也不会终止真实网络活动。生产队列应区分“调用方结束等待”和“底层实际完成”,避免名义并发限制失效。当前排队取消在获得槽位时生效;如需立即拒绝,应给排队条目加状态、监听器和移除逻辑,并确保只结算一次。

5. 原题实现需要修正的地方

  1. 配置传入 limit,比较时却写死 < 5,配置没有生效。
  2. 失败路径没有递减在途计数、没有推进队列,若前几个请求都失败,后面的请求永远不启动。
  3. 全局提示分支既不 resolve 也不 reject,调用方永久 pending;不能用“提示过了”代替返回结果结算。
  4. Proxy 只传一个回调,丢失 then 的第二个参数;对 await 的 thenable 接管也不正确。
  5. 使用随机延时和 debugger 无法稳定证明队列行为,应使用可手动完成/拒绝的测试工厂。

6. 验证与面试追问

至少验证:limit 为 0/小数时报错;超过上限的请求等待;前一项同步抛错或异步拒绝后下一项仍启动;每个结果只结算一次;局部模式不提示、全局模式只提示一次;报告函数抛错时调用方仍拿到原错误;预先取消的请求不进入 transport。

并发与速率有什么区别? 三个槽位遇到很快的接口仍可能每秒发送成百上千次;限速还需时间维度。

为什么全局模式仍要 catch? 提示是 UI 副作用,Promise 的拒绝是业务结果。保留拒绝才能正确关闭 loading、撤销乐观更新并避免把失败当成功。

错误处理放进调度器好吗? 调度器知道任务结束与否,不知道是否应弹窗。将队列、协议和提示拆开后,可以独立验证失败不会卡队列,也能复用到非 HTTP 任务。

7. 来源与取舍

来自 JS异步编程.pdf 第 1 页的提问及第 12–13 页的 request client 代码。保留持续并发池、每次请求独立 Promise 和错误提示归属问题;以显式契约替换无法完整判定的“自动识别 catch”,修复失败挂起和写死并发数。与固定批任务/取消延迟重复的基础知识链接到原算法专题。