面试那道 fetchWithRetry 的题,我当时脑子里一片空白。
不是不会写 fetch,不是不理解"重试"的概念,但就是不知道代码该从哪里开始——function fetchWithRetry 之后,下一行该写什么?
后来我才意识到:这类题的难点不是逻辑,是心智模型没建立起来。一旦你理解了"函数包裹"这件事的本质,withRetry、withLimit、withDelay,以及它们的任意组合,都会变成同一道题的不同变体。
这篇文章是我梳理这个知识点的完整过程,希望对遇到同类题目时感到犯怵的你有所帮助。
先建立心智模型:这类题在问什么?
"函数包裹"题的本质,是高阶函数(Higher-Order Function)+ 控制流管理。
它们有一个共同的结构:
原始能力(一个 async 函数)
↓ 被包裹
增强能力(重试 / 限流 / 延迟)
↓ 对外暴露
与原始函数相同的调用接口
注意最后一点:"与原始函数相同的调用接口"。这是关键——包裹之后,调用方感知不到包裹的存在。这其实是装饰器模式(Decorator Pattern)的思想。
理解了这个结构,再看题目时,你要做的第一件事就是问自己:
这个包裹需要在哪个环节拦截?拦截之后做什么?最终还是要交回原始函数去执行。
Part 1:fetchWithRetry —— 最经典的起点
题目描述
实现
fetchWithRetry(url, options, retries),在请求失败时自动重试最多retries次。
先想清楚,再动手
失败时重试,意味着:
- 先正常执行一次
fetch - 如果成功 → 直接返回结果
- 如果失败 → 判断还有没有剩余次数
- 有剩余 → 递归或循环再执行一次
- 次数耗尽 → 把最后一次的错误抛出去
这是一个天然适合递归的场景,因为每次重试本质上是"带着更少的剩余次数,重新做同一件事"。
// 环境:浏览器 / Node.js 18+
// 场景:带自动重试的 fetch 封装
async function fetchWithRetry(url, options = {}, retries = 3) {
try {
const response = await fetch(url, options);
// fetch 本身在网络层面不会抛错,HTTP 4xx/5xx 需要手动判断
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`); // ← 这里 throw
}
return response; // 成功时返回
} catch (err) { // ← throw 会到这里
if (retries <= 0) {
throw err; // 重试耗尽,抛给外部调用者
}
return fetchWithRetry(url, options, retries - 1); // 递归重试
}
}
// usage
fetchWithRetry('https://api.example.com/data', {}, 3)
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error('All retries failed:', err));
有几个容易踩的坑值得注意:
坑 1:fetch 失败和请求失败是两回事。
网络断开时 fetch 会 reject,但服务器返回 500 时,fetch 是 resolve 的——只是 response.ok 为 false。如果你只 catch 不检查 response.ok,500 错误不会被重试。
坑 2:递归的终止条件要写对。
retries <= 0 而不是 retries === 0,防御性更强。
坑 3:最后一次失败要把错误抛出去。
调用方需要知道"所有重试都失败了",所以 throw err 不能省。
在 try 中的这个 throw 会进入 同一个 try...catch 结构里的 catch 块,而不是直接作为最终结果抛出去。
关键点:
| 情况 | 结果 |
|---|---|
response.ok === false 时 throw | 被当前函数的 catch 捕获 |
catch 里 retries > 0 | 递归调用自身,发起重试 |
catch 里 retries <= 0 | throw err 抛给外部调用者 |
简单记忆:
throw → 最近的 try...catch 捕获 → 没捕获再往外抛
这里 throw new Error(...) 和 catch 是同一层的,所以一定会被接住。只有 catch 里的那个 throw err 才是真正对外抛的。
这也解释了为什么这个设计能工作:HTTP 错误被转成异常,走统一的重试/失败逻辑,和网络断开时的 fetch reject 行为一致。
Part 2:从具体到通用 —— withRetry 高阶函数
fetchWithRetry 解决了具体问题,但如果我有一个 queryDatabase 函数,也想让它支持重试呢?难道要再写一个 queryDatabaseWithRetry?
这时候就需要把"重试"这个能力提取出来,变成一个通用的包裹函数。
核心思路
withRetry(fn, retries) → 返回一个新函数
新函数的签名和 fn 完全相同
调用新函数时,内部自动处理重试逻辑
// 环境:浏览器 / Node.js 18+
// 场景:通用重试高阶函数
function withRetry(fn, retries = 3) {
// return a wrapper that has the same signature as fn
return async function (...args) {
try {
return await fn(...args);
} catch (err) {
if (retries <= 0) throw err;
// create a new wrapper with retries - 1, call it with the same args
return withRetry(fn, retries - 1)(...args);
}
};
}
// usage — wrap any async function
const resilientFetch = withRetry(fetch, 3);
const resilientQuery = withRetry(queryDatabase, 5);
resilientFetch('https://api.example.com/data')
.then(res => res.json())
.catch(err => console.error('Failed after retries:', err));
注意 ...args 的使用——这是让包裹函数"透明"的关键。包裹层不需要知道原函数接受什么参数,只负责透传。
Part 3:withDelay —— 在时间维度上的控制
题目描述
实现
withDelay(fn, delay),让函数调用延迟delay毫秒后执行。
这比 withRetry 简单一些,核心是封装一个 sleep 工具函数。
// 环境:浏览器 / Node.js 18+
// 场景:延迟执行包裹函数
// sleep utility
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
function withDelay(fn, delay = 1000) {
return async function (...args) {
await sleep(delay);
return fn(...args);
};
}
// usage
const delayedFetch = withDelay(fetch, 2000);
delayedFetch('https://api.example.com/data'); // executes after 2s
延伸变体:withRetry + 指数退避(Exponential Backoff)
在实际场景中,重试往往需要配合延迟——立刻重试可能会让已经超载的服务更糟糕。指数退避是一种常见策略:第 1 次重试等 1s,第 2 次等 2s,第 3 次等 4s……
// 场景:带指数退避的重试(更接近生产实践)
function withRetryAndBackoff(fn, retries = 3, baseDelay = 1000) {
return async function (...args) {
let attempt = 0;
while (attempt <= retries) {
try {
return await fn(...args);
} catch (err) {
if (attempt === retries) throw err;
const delay = baseDelay * Math.pow(2, attempt);
await sleep(delay); // 1s, 2s, 4s ...
attempt++;
}
}
};
}
这里我换成了 while 循环写法,个人觉得比递归更直观地表达了"尝试次数"的概念,两种方式都是合理的。
Part 4:withLimit —— 并发控制
题目描述
实现
withLimit(fn, limit),限制函数的最大并发执行数为limit。
这道题的难度上了一个台阶,因为它需要跨多次调用维持状态。
想清楚状态是什么
并发控制需要知道:
- 当前有多少个调用正在执行(
running计数器) - 当超出限制时,新的调用要排队等待(
queue) - 当某个调用完成时,要从队列中取出下一个来执行
// 环境:浏览器 / Node.js 18+
// 场景:并发数量限制的高阶函数
function withLimit(fn, limit = 3) {
let running = 0;
const queue = []; // { args, resolve, reject }
// this is the core scheduler
function schedule() {
while (running < limit && queue.length > 0) {
const { args, resolve, reject } = queue.shift();
running++;
fn(...args)
.then(resolve)
.catch(reject)
.finally(() => {
running--;
schedule(); // when one finishes, try to run the next
});
}
}
return function (...args) {
return new Promise((resolve, reject) => {
queue.push({ args, resolve, reject });
schedule();
});
};
}
// usage
const limitedFetch = withLimit(fetch, 2); // at most 2 concurrent fetches
const urls = [
'https://api.example.com/1',
'https://api.example.com/2',
'https://api.example.com/3',
'https://api.example.com/4',
];
Promise.all(urls.map(url => limitedFetch(url)));
// only 2 fetches run at a time; others wait in queue
这里有一个设计决策值得思考:状态(running 和 queue)保存在 withLimit 的闭包里,而不是每次调用时创建。这意味着所有通过 limitedFetch 发出的请求共享同一个计数器,这才能实现真正的全局并发控制。
Part 5:组合使用 —— 真实场景下的叠加
面试题可能会问:"如果我既想要重试,又想要并发限制,又想要延迟,怎么办?"
一种思路是手动组合:
// 场景:组合多个 wrapper
let enhancedFetch = fetch;
enhancedFetch = withLimit(enhancedFetch, 5); // max 5 concurrent
enhancedFetch = withRetryAndBackoff(enhancedFetch, 3, 500); // retry 3 times with backoff
// now enhancedFetch is limited + retried + delayed
enhancedFetch('https://api.example.com/data');
但这有一个问题:组合的顺序很重要,理解起来需要一定心智负担。
另一种更优雅的方式是实现一个 compose 或 pipe:
// 函数式组合工具
const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);
// each wrapper is partially applied with its config
const withLimit3 = (fn) => withLimit(fn, 3);
const withRetry3 = (fn) => withRetry(fn, 3);
const withDelay1s = (fn) => withDelay(fn, 1000);
const enhancedFetch = pipe(withLimit3, withRetry3, withDelay1s)(fetch);
这种方式让每个包裹层的配置和组合逻辑分离,可读性更好。
面试快速参考
遇到这类题时,可以用这个思路框架快速定位:
| 题目类型 | 核心状态 | 控制流关键词 | 容易忘的细节 |
|---|---|---|---|
| withRetry | 剩余次数 | try/catch + 递归 or 循环 | response.ok 检查;最后一次要 throw |
| withDelay | 无 | await sleep(ms) | sleep 是 Promise wrapper |
| withLimit | running 计数 + 队列 | .finally() 调度 | 状态要在闭包外;schedule 要在 finally 中调用 |
| 组合 | 各自状态 | 嵌套包裹 or compose/pipe | 顺序影响语义 |
延伸思考
梳理完这些之后,我反而产生了一些新的问题:
withLimit的队列是 FIFO 的。如果需要优先级队列(VIP 请求优先执行),结构会怎么变?- 这些包裹函数都没有处理"取消"(abort)。如果调用方想取消排队中的请求,应该怎么暴露接口?
withRetry对所有错误都重试,但有些错误(比如 401 鉴权失败)是不应该重试的——如何让调用方定义"哪些错误才需要重试"?
这些问题可能在更深入的面试或实际项目中遇到,目前先记录下来作为后续探索的方向。
小结
回头看,当初面对 fetchWithRetry 卡壳的原因很清楚了——我没有意识到这是一类有固定结构的题,试图在没有模型的情况下"硬想"具体实现。
一旦理解了"函数包裹 = 接受函数 → 返回函数 → 内部拦截控制流 → 透传参数和结果"这个结构,剩下的就是填充具体的控制逻辑。
这类题的本质是装饰器模式在异步函数上的应用,而它考察的核心能力,是你能不能把"重试这件事"和"这次具体请求的数据"分开思考。
希望这篇文章对你有帮助。如果你有不同的实现思路或者遇到了更复杂的变体,欢迎交流。
参考资料
- MDN - Using Fetch - Fetch API 官方文档,包含
response.ok等细节 - MDN - Promise - Promise 机制基础
- Exponential Backoff And Jitter - AWS Blog - 指数退避在实际系统中的应用