手写实现 Promise.retry:带有指数退避(Exponential Backoff)策略的重试机制
大家好,今天我们来深入探讨一个在现代前端开发中非常实用的技术点:如何手写一个支持指数退避策略的 Promise.retry 方法。这个功能看似简单,实则蕴含了对异步编程、错误处理和系统稳定性的深刻理解。
无论你是刚接触 Promise 的新手,还是已经熟练使用 async/await 的资深开发者,这篇文章都会带你从零开始构建一个健壮、可配置、生产级可用的重试机制。
一、为什么要实现 Promise.retry?
在实际项目中,我们经常会遇到这样的场景:
- 调用第三方 API 失败(网络波动、服务暂时不可用)
- 数据库连接超时或断开
- 文件上传失败(比如 CDN 暂时无响应)
这些情况往往不是永久性的,而是短暂的、可恢复的错误。如果直接抛出异常或者让用户看到“请求失败”,用户体验会很差。
这时,“重试”就变得非常重要——自动尝试重新执行任务,直到成功或达到最大重试次数。
但注意:不能盲目重试!
频繁重试可能造成雪崩效应(如大量并发请求打爆服务器),也浪费资源。所以我们需要一种智能的重试策略——这就是 指数退避(Exponential Backoff) 的由来。
二、什么是指数退避?它为什么有效?
✅ 定义:
指数退避是一种重试策略,每次失败后等待的时间呈指数增长(通常是 2^n 秒),避免短时间内重复请求导致系统压力过大。
举个例子:
| 重试次数 | 等待时间(秒) |
|———-|—————-|
| 第1次 | 1 |
| 第2次 | 2 |
| 第3次 | 4 |
| 第4次 | 8 |
| 第5次 | 16 |
这种方式可以有效缓解服务器负载,同时给予足够的时间让临时故障自行恢复。
🔍 为什么有效?
- 防止雪崩:不会瞬间发送大量请求。
- 适应性好:第一次失败等1秒,第二次等2秒……越往后等待越长,给系统喘息空间。
- 符合 TCP/IP 协议设计思想:很多底层协议(如 HTTP、TCP)都采用类似策略来应对拥塞控制。
三、目标:手写一个通用的 Promise.retry
我们要实现的方法签名如下:
Promise.retry(
fn: () => Promise<any>,
options?: {
retries?: number; // 最大重试次数,默认 3
backoff?: (attempt: number) => number; // 自定义退避函数,默认指数退避
shouldRetry?: (error: Error) => boolean; // 是否应该重试,默认判断是否为网络错误
}
): Promise<any>
这个方法接受一个异步函数 fn,以及可选的配置项,最终返回一个新的 Promise。
四、核心逻辑拆解与代码实现
我们一步一步来构建这个函数,确保每一步都有明确的目的和测试依据。
Step 1:基础结构搭建
function retry(fn, options = {}) {
const {
retries = 3,
backoff = (attempt) => Math.pow(2, attempt) * 1000, // 默认:2^attempt * 1s
shouldRetry = (err) => err.name === 'NetworkError' || err.code === 'ECONNREFUSED'
} = options;
let attempt = 0;
function execute() {
return fn().catch(err => {
if (attempt >= retries || !shouldRetry(err)) {
throw err;
}
attempt++;
const delay = backoff(attempt);
console.log(`第 ${attempt} 次重试,延迟 ${delay / 1000}s`);
return new Promise(resolve => setTimeout(resolve, delay))
.then(() => execute()); // 递归调用自身进行下一次尝试
});
}
return execute();
}
✅ 这是一个简洁而完整的版本,满足以下特性:
- 支持自定义最大重试次数(默认3次)
- 支持自定义退避函数(默认指数退避)
- 支持自定义是否重试(默认只对网络类错误重试)
- 使用递归 +
setTimeout实现非阻塞等待
Step 2:增强版 —— 加入更多控制选项
为了更贴近真实业务需求,我们可以扩展参数:
| 参数名 | 类型 | 默认值 | 描述 |
|---|---|---|---|
| retries | number | 3 | 最大重试次数 |
| backoff | (attempt: number) => number | (a) => 2^a * 1000 |
退避时间计算函数(单位毫秒) |
| shouldRetry | (err: Error) => boolean | 网络错误才重试 | 判断是否应继续重试 |
| onRetry | (attempt: number, error: Error) => void | noop | 每次重试前回调(可用于日志) |
更新后的完整版本:
function retry(fn, options = {}) {
const {
retries = 3,
backoff = (attempt) => Math.pow(2, attempt) * 1000,
shouldRetry = (err) => err.name === 'NetworkError' || err.code === 'ECONNREFUSED',
onRetry = () => {} // 可用于记录日志或埋点
} = options;
let attempt = 0;
function execute() {
return fn().catch(err => {
if (attempt >= retries || !shouldRetry(err)) {
return Promise.reject(err);
}
attempt++;
onRetry(attempt, err);
const delay = backoff(attempt);
console.log(`[Retry] 第 ${attempt} 次失败,等待 ${delay / 1000}s 后重试`);
return new Promise(resolve => setTimeout(resolve, delay))
.then(() => execute());
});
}
return execute();
}
五、实战示例:模拟真实场景
让我们用几个例子验证我们的 retry 函数是否工作正常。
示例 1:模拟网络请求失败(适合重试)
const fakeApiCall = () => {
const success = Math.random() > 0.7; // 30% 成功率
return new Promise((resolve, reject) => {
setTimeout(() => {
if (success) {
resolve({ data: "OK" });
} else {
reject(new Error("NetworkError"));
}
}, 100);
});
};
retry(fakeApiCall, {
retries: 5,
shouldRetry: (err) => err.message.includes("Network")
}).then(result => {
console.log("✅ 请求成功:", result);
}).catch(err => {
console.error("❌ 最终失败:", err.message);
});
运行结果可能是:
[Retry] 第 1 次失败,等待 1s 后重试
[Retry] 第 2 次失败,等待 2s 后重试
[Retry] 第 3 次失败,等待 4s 后重试
✅ 请求成功: { data: "OK" }
说明:即使前几次失败,只要不超过最大重试次数,最终也能成功!
示例 2:手动控制重试行为(比如某些错误不应该重试)
const dangerousApiCall = () => {
return new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("InvalidInput")); // 不是网络问题,不应重试
}, 100);
});
};
retry(dangerousApiCall, {
retries: 3,
shouldRetry: (err) => err.message.includes("Network") // 明确只对网络错误重试
}).then(result => {
console.log("✅ 成功");
}).catch(err => {
console.error("❌ 不应该重试,直接失败:", err.message); // 输出 InvalidInput
});
输出:
❌ 不应该重试,直接失败: InvalidInput
✅ 正确跳过了重试流程,避免无效操作。
六、高级技巧:自定义退避策略
有时候我们不希望简单的 2^n,而是想加一点随机抖动(Jitter),防止多个客户端同时重试同一时刻造成雪崩。
✅ 加入随机抖动的指数退避
function jitteredBackoff(attempt) {
const baseDelay = Math.pow(2, attempt) * 1000;
const jitter = Math.random() * 1000; // ±1s 随机抖动
return baseDelay + jitter;
}
retry(apiCall, {
retries: 5,
backoff: jitteredBackoff,
onRetry: (attempt, err) => console.log(`⚠️ 第${attempt}次重试`)
});
这样可以进一步提升系统的弹性和稳定性。
七、性能考量与最佳实践
虽然 retry 很有用,但也有一些坑需要注意:
| 注意事项 | 建议做法 |
|---|---|
| ❗ 不要无限重试 | 设置合理的 retries 上限(通常 3~5 次) |
| ❗ 不要对所有错误都重试 | 使用 shouldRetry 过滤非临时错误(如权限不足、数据格式错误) |
| ❗ 不要忽略失败原因 | 在 onRetry 中打印日志,便于排查问题 |
| ❗ 不要滥用 | 如果某个接口经常失败,说明它本身有问题,应该优化而不是靠重试解决 |
💡 小贴士:对于关键业务(如支付、注册),建议结合熔断机制(Circuit Breaker)一起使用,防止单点故障扩散。
八、对比其他方案(为什么不用第三方库?)
很多人可能会问:“为什么不直接用 p-retry 或 bluebird-retry?”
确实有成熟的库可用,但自己实现的好处在于:
- 完全可控:你可以根据业务定制退避规则、日志、监控等
- 轻量无依赖:不需要引入额外包,适合小型项目或嵌入式环境
- 学习价值高:掌握异步控制流的本质,提升工程素养
当然,如果你团队已有成熟工具链(如 Sentry、Prometheus),也可以封装成插件复用。
九、总结:你现在已经掌握了什么?
今天我们完成了以下内容:
✅ 理解了指数退避的核心原理及其适用场景
✅ 手写了完整的 Promise.retry 实现,包含灵活配置
✅ 提供了多种实际应用场景的测试案例
✅ 探讨了高级玩法(随机抖动、熔断配合)
✅ 给出了性能优化和最佳实践建议
现在你可以把这个函数直接放进你的工具库中,在任何地方调用它来优雅地处理临时失败的异步任务。
十、附录:完整代码清单(可直接复制使用)
/**
* 带指数退避的 Promise 重试机制
*/
function retry(fn, options = {}) {
const {
retries = 3,
backoff = (attempt) => Math.pow(2, attempt) * 1000,
shouldRetry = (err) => err.name === 'NetworkError' || err.code === 'ECONNREFUSED',
onRetry = () => {}
} = options;
let attempt = 0;
function execute() {
return fn().catch(err => {
if (attempt >= retries || !shouldRetry(err)) {
return Promise.reject(err);
}
attempt++;
onRetry(attempt, err);
const delay = backoff(attempt);
console.log(`[Retry] 第 ${attempt} 次失败,等待 ${delay / 1000}s 后重试`);
return new Promise(resolve => setTimeout(resolve, delay))
.then(() => execute());
});
}
return execute();
}
📌 使用方式:
retry(async () => {
const res = await fetch('/api/data');
if (!res.ok) throw new Error('NetworkError');
return res.json();
}, {
retries: 3,
shouldRetry: (err) => err.message.includes('Network'),
onRetry: (attempts, err) => console.warn(`重试 ${attempts} 次`, err)
}).then(data => console.log('成功:', data));
希望这篇讲解能帮你真正理解并应用 Promise.retry,不仅是在面试中加分,更是在日常开发中写出更鲁棒的代码。记住:好的程序员不是只会用框架的人,而是懂得底层原理、敢于动手实现的人。
谢谢大家!