如何手写 Promise 重试函数?需要处理哪些边界?
简化版
Promise 重试函数用于把一个可能失败的异步任务包装成“失败后再试几次”。核心是:传入返回 Promise 的函数 fn,失败后判断剩余次数,必要时等待一段时间再递归或循环调用。边界包括:只重试失败、不重试成功;次数语义要清楚;支持延迟和退避;保留最后一次错误;不要对已经创建好的 Promise 重试。
详细版
重试函数的重点不是语法,而是“每次重试都要重新执行任务函数”。如果传入的是 Promise 实例,它已经开始执行,失败后无法重新发起。
| 点 | 说明 |
|---|---|
| 入参 | fn 必须是函数,调用后返回 Promise |
| 次数 | retries 表示失败后最多再试几次 |
| 延迟 | 可固定延迟,也可指数退避 |
| 错误 | 次数耗尽后抛出最后一次错误 |
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function retry<T>(fn: () => Promise<T>, retries = 3, delay = 0): Promise<T> {
try {
return await fn()
} catch (err) {
if (retries <= 0) throw err
if (delay > 0) await sleep(delay)
return retry(fn, retries - 1, delay)
}
}
面试里要特别说清楚:重试的是“任务函数”,不是已经执行过的 Promise。
完整版教学
一、题目考察点
这道题表面是 Promise,实际考察异步控制流、错误传递、递归或循环、次数边界和工程思维。
很多候选人能写出 happy path,但容易忽略“Promise 一旦创建就开始执行”,所以传参方式很关键。
二、为什么不能传 Promise 实例
错误写法是:
retry(fetch('/api'), 3)
fetch('/api') 已经发出请求了。失败后你只能重新等待同一个失败结果,不能重新请求。
正确写法是:
retry(() => fetch('/api'), 3)
每次调用 fn() 都会创建新的异步任务。
三、递归版本怎么写
递归版本最直观:执行任务,成功直接返回,失败判断是否还有次数,有就等待后再次调用。
async function retry<T>(fn: () => Promise<T>, retries: number): Promise<T> {
try {
return await fn()
} catch (error) {
if (retries === 0) throw error
return retry(fn, retries - 1)
}
}
这里 retries 表示“失败后还可以重试几次”。如果 retries = 3,最多执行 4 次。
四、循环版本怎么写
循环版本更容易控制退避、日志和中断。
async function retryLoop<T>(fn: () => Promise<T>, retries = 3, delay = 0): Promise<T> {
let lastError: unknown
for (let i = 0; i <= retries; i++) {
try {
return await fn()
} catch (error) {
lastError = error
if (i < retries && delay > 0) await sleep(delay)
}
}
throw lastError
}
它的优势是不会形成很深的 Promise 链,扩展也方便。
五、指数退避和抖动
真实请求重试不能所有客户端同时固定 1 秒重试,否则服务恢复时会被同时打爆。常见做法是指数退避和随机抖动。
| 策略 | 示例 | 作用 |
|---|---|---|
| 固定延迟 | 1000ms | 简单可控 |
| 指数退避 | 500、1000、2000ms | 减轻下游压力 |
| 随机抖动 | 延迟加随机值 | 避免请求同时涌入 |
六、哪些错误不该重试
不是所有失败都适合重试。网络超时、503、限流可以重试;参数错误、鉴权失败、业务校验失败通常不该重试。
可以增加 shouldRetry 参数:
type RetryOptions = {
retries: number
shouldRetry?: (error: unknown) => boolean
}
七、常见误区与追问
- 误区:传入 Promise 就能重试。 Promise 已经执行,必须传返回 Promise 的函数。
- 误区:所有错误都应该重试。 业务错误和鉴权错误通常重试也不会成功。
- 误区:重试次数就是总执行次数。 要先约定清楚是“额外重试次数”还是“总尝试次数”。
- 追问:如何支持指数退避? 每次失败后按尝试次数计算 delay,例如
base * 2 ** attempt。 - 追问:如何保留错误? 次数耗尽时抛出最后一次捕获到的错误。
- 追问:如何支持取消? 可以传入
AbortSignal,每次重试前检查是否已取消。
八、加强记忆
手写重试记住四件事:任务函数、失败再试、次数耗尽抛最后错误、延迟退避防雪崩。答题时把“不能传 Promise 实例”点出来,基本就稳了一半。