实现一个 HTTP 请求重试系统
在现代 Web 应用中,网络请求的可靠性直接影响用户体验。临时性的网络波动、服务器过载、或者短暂的服务不可用,都可能导致请求失败。一个设计良好的重试机制,可以显著提升应用的健壮性。
本文分享重试系统的实现思路,重点关注错误分类、配置设计和边界情况处理。相比指数退避这类常见算法,工程实践中更大的挑战在于:如何让系统在各种异常场景下都能正确工作。
核心实现
先看整体结构:
typescript
export class RetryManager {
private config: Required<RetryConfig>;
async executeWithRetry<T>(
fn: () => Promise<T>,
requestConfig?: RetryConfig,
): Promise<T> {
const config = { ...this.config, ...requestConfig };
let lastError: any;
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
try {
return await fn();
} catch (error: any) {
lastError = error;
if (!this.shouldRetry(error, attempt, config.maxRetries, config)) {
throw error;
}
const delay = await this.calculateDelay(attempt, error, config);
if (config.onRetry) {
config.onRetry(attempt + 1, error);
}
await this.sleep(delay);
}
}
throw lastError;
}
}
几个关键设计点:
- 循环次数是
maxRetries + 1- 包括初始请求和所有重试 - 保存最后的错误 - 确保所有重试失败后能抛出准确的错误信息
- 支持回调机制 - 方便集成日志和监控系统
错误分类:哪些错误应该重试?
这是实现重试机制时的第一个核心问题。不是所有错误都应该重试:
typescript
// 应该重试的错误(临时性错误)
const RETRYABLE_STATUSES = [
408, // Request Timeout - 请求超时
429, // Too Many Requests - 限流
500, // Internal Server Error - 服务器内部错误
502, // Bad Gateway - 网关错误
503, // Service Unavailable - 服务不可用
504, // Gateway Timeout - 网关超时
];
// 不应该重试的错误(永久性错误)
const NON_RETRYABLE = [
400, // Bad Request - 参数错误,重试也不会成功
401, // Unauthorized - 需要重新登录
403, // Forbidden - 权限不足
404, // Not Found - 资源不存在
422, // Unprocessable Entity - 数据验证失败
];
实现时还需要考虑网络错误(没有 HTTP 状态码的情况):
typescript
private shouldRetry(
error: any,
attempt: number,
maxRetries: number,
config: Required<RetryConfig>
): boolean {
if (attempt >= maxRetries) return false;
const status = error.response?.status || error.status;
// 网络错误(断网、DNS 失败、连接超时等)应该重试
if (!status) return true;
return config.retryableStatuses.includes(status);
}
请求级别配置
在实际应用中,不同的接口可能需要不同的重试策略。支持全局配置和请求级别配置的覆盖:
typescript
// 全局配置
const retryManager = new RetryManager({
maxRetries: 3,
retryDelay: 1000,
exponentialBackoff: true,
});
// 请求级别覆盖全局配置
await retryManager.executeWithRetry(() => fetch("/api/critical"), {
maxRetries: 5, // 重要接口多重试几次
retryDelay: 2000,
});
实现方式:
typescript
async executeWithRetry<T>(
fn: () => Promise<T>,
requestConfig?: RetryConfig
): Promise<T> {
// 请求级别配置覆盖全局配置
const config = requestConfig
? { ...this.config, ...requestConfig }
: this.config;
// 使用合并后的配置执行重试逻辑
// ...
}
这种设计让使用者可以灵活控制每个请求的重试行为,而不需要创建多个 RetryManager 实例。
Retry-After 头:尊重服务器的指示
服务器可以通过 Retry-After 响应头告诉客户端何时重试(RFC 7231)。这在处理限流(429)和服务不可用(503)时特别重要:
typescript
private parseRetryAfter(error: any): number | null {
const retryAfter =
error.response?.headers?.['retry-after'] ||
error.response?.headers?.['Retry-After'];
if (!retryAfter) return null;
// 格式 1: 秒数 "120"
const seconds = parseInt(retryAfter, 10);
if (!isNaN(seconds)) {
return seconds * 1000;
}
// 格式 2: HTTP 日期 "Wed, 21 Oct 2015 07:28:00 GMT"
try {
const date = new Date(retryAfter);
const delay = date.getTime() - Date.now();
return delay > 0 ? delay : null;
} catch {
return null;
}
}
在计算延迟时,优先使用服务器的指示:
typescript
private async calculateDelay(
attempt: number,
error: any,
config: Required<RetryConfig>
): Promise<number> {
// 1. 优先使用服务器指示
const retryAfter = this.parseRetryAfter(error);
if (retryAfter !== null) return retryAfter;
// 2. 使用指数退避
if (config.exponentialBackoff) {
const exponentialDelay = config.retryDelay * Math.pow(2, attempt);
const jitter = Math.random() * 1000;
return exponentialDelay + jitter;
}
// 3. 固定延迟
return config.retryDelay;
}
指数退避与随机抖动
固定延迟会导致"惊群效应"(thundering herd):大量客户端同时重试,可能压垮刚恢复的服务器。
使用指数退避 + 随机抖动可以有效缓解这个问题:
typescript
// 指数退避:1s, 2s, 4s, 8s...
const exponentialDelay = baseDelay * Math.pow(2, attempt);
// 随机抖动:避免所有客户端同时重试
const jitter = Math.random() * 1000;
return exponentialDelay + jitter;
这样,即使多个客户端同时遇到错误,它们的重试时间也会分散开来。
使用示例
基础用法
typescript
const retryManager = new RetryManager({
maxRetries: 3,
retryDelay: 1000,
exponentialBackoff: true,
onRetry: (attempt, error) => {
console.log(`Retry ${attempt}:`, error.message);
},
});
const data = await retryManager.executeWithRetry(() =>
fetch("/api/data").then((r) => r.json()),
);
不同场景的配置策略
typescript
// 读操作:可以多重试
const readRetry = new RetryManager({
maxRetries: 5,
retryDelay: 1000,
});
// 写操作:谨慎重试(避免重复提交)
const writeRetry = new RetryManager({
maxRetries: 2,
retryDelay: 2000,
retryableStatuses: [408, 503, 504], // 只重试明确的临时错误
});
// 关键接口:更激进的重试
const criticalRetry = new RetryManager({
maxRetries: 5,
retryDelay: 500,
exponentialBackoff: true,
});
集成监控系统
typescript
const retryManager = new RetryManager({
maxRetries: 3,
onRetry: (attempt, error) => {
// 上报到监控系统
monitor.recordRetry({
attempt,
error: error.message,
status: error.status,
url: error.config?.url,
timestamp: Date.now(),
});
},
});
边界情况处理
生产环境中会遇到各种边界情况,需要妥善处理:
空错误对象
typescript
private shouldRetry(error: any, ...): boolean {
if (!error) return true; // 网络错误,应该重试
// ...
}
缺少响应对象
typescript
const status = error.response?.status || error.status;
if (!status) return true; // 没有状态码 = 网络错误
Retry-After 解析失败
typescript
try {
const date = new Date(retryAfter);
const delay = date.getTime() - Date.now();
return delay > 0 ? delay : null; // 负数(过去的时间)返回 null
} catch {
return null; // 解析失败,降级到指数退避
}
大小写不敏感的头处理
typescript
const retryAfter =
error.response?.headers?.["retry-after"] ||
error.response?.headers?.["Retry-After"];
测试策略
基础重试测试
typescript
it("should retry on retryable errors", async () => {
const retryManager = new RetryManager({
maxRetries: 3,
retryDelay: 100,
});
let attempts = 0;
const fn = async () => {
attempts++;
if (attempts < 3) {
throw { status: 503 };
}
return "success";
};
const result = await retryManager.executeWithRetry(fn);
expect(attempts).toBe(3);
expect(result).toBe("success");
});
不重试非可重试错误
typescript
it("should not retry on non-retryable errors", async () => {
const retryManager = new RetryManager({ maxRetries: 3 });
let attempts = 0;
const fn = async () => {
attempts++;
throw { status: 404 }; // Not Found
};
await expect(retryManager.executeWithRetry(fn)).rejects.toThrow();
expect(attempts).toBe(1); // 只尝试一次
});
Retry-After 头测试
typescript
it("should respect Retry-After header", async () => {
const retryManager = new RetryManager({ maxRetries: 3 });
const error = {
status: 429,
response: {
headers: { "retry-after": "2" }, // 2 秒后重试
},
};
const fn = vi.fn().mockRejectedValueOnce(error).mockResolvedValue("success");
await retryManager.executeWithRetry(fn);
// 验证延迟时间接近 2000ms
});
实战考虑
重复提交问题
写操作重试可能导致重复提交。解决方案:
方案 1:使用幂等性设计
typescript
async function submitOrder(orderData) {
return await client.post("/api/orders", {
...orderData,
requestId: generateUUID(), // 服务器根据 ID 去重
});
}
方案 2:限制写操作的重试
typescript
const writeRetry = new RetryManager({
maxRetries: 1, // 只重试一次
retryableStatuses: [408, 503, 504], // 只重试明确的临时错误
});
避免重试风暴
使用指数退避 + 随机抖动:
typescript
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
这样可以将重试请求分散到不同的时间点,避免同时压垮服务器。
监控和告警
通过回调机制集成监控:
typescript
const retryManager = new RetryManager({
onRetry: (attempt, error) => {
// 记录重试事件
logger.warn("Request retry", {
attempt,
error: error.message,
status: error.status,
});
// 重试次数过多时告警
if (attempt >= 3) {
alerting.send("High retry rate detected");
}
},
});
总结
本文介绍了 HTTP 请求重试机制的工程化实现,重点包括:
- 错误分类:区分临时性错误和永久性错误
- Retry-After 头处理:尊重服务器的指示
- 请求级配置:支持灵活的配置覆盖
- 边界情况处理:处理各种异常场景
- 测试策略:确保代码的可靠性
重试机制看似简单,但要做到生产级别,需要考虑很多细节。希望本文能为你的实践提供参考。