请求客户端:错误提示归属与持续并发队列
从自动识别 catch 的面试题出发,设计兼容 await 的错误契约与失败后仍能继续工作的请求队列。
请求层负责传递结果,调用层决定如何恢复业务。先把失败、提示和队列释放的契约说清楚,再实现限流。
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. 原题实现需要修正的地方
- 配置传入
limit,比较时却写死< 5,配置没有生效。 - 失败路径没有递减在途计数、没有推进队列,若前几个请求都失败,后面的请求永远不启动。
- 全局提示分支既不 resolve 也不 reject,调用方永久 pending;不能用“提示过了”代替返回结果结算。
- Proxy 只传一个回调,丢失
then的第二个参数;对 await 的 thenable 接管也不正确。 - 使用随机延时和
debugger无法稳定证明队列行为,应使用可手动完成/拒绝的测试工厂。
6. 验证与面试追问
至少验证:limit 为 0/小数时报错;超过上限的请求等待;前一项同步抛错或异步拒绝后下一项仍启动;每个结果只结算一次;局部模式不提示、全局模式只提示一次;报告函数抛错时调用方仍拿到原错误;预先取消的请求不进入 transport。
并发与速率有什么区别? 三个槽位遇到很快的接口仍可能每秒发送成百上千次;限速还需时间维度。
为什么全局模式仍要 catch? 提示是 UI 副作用,Promise 的拒绝是业务结果。保留拒绝才能正确关闭 loading、撤销乐观更新并避免把失败当成功。
错误处理放进调度器好吗? 调度器知道任务结束与否,不知道是否应弹窗。将队列、协议和提示拆开后,可以独立验证失败不会卡队列,也能复用到非 HTTP 任务。
7. 来源与取舍
来自 JS异步编程.pdf 第 1 页的提问及第 12–13 页的 request client 代码。保留持续并发池、每次请求独立 Promise 和错误提示归属问题;以显式契约替换无法完整判定的“自动识别 catch”,修复失败挂起和写死并发数。与固定批任务/取消延迟重复的基础知识链接到原算法专题。