HTTP 请求失败了怎么办?深入理解重试机制的工程实践

53 阅读6分钟

实现一个 HTTP 请求重试系统

在现代 Web 应用中,网络请求的可靠性直接影响用户体验。临时性的网络波动、服务器过载、或者短暂的服务不可用,都可能导致请求失败。一个设计良好的重试机制,可以显著提升应用的健壮性。

本文分享重试系统的实现思路,重点关注错误分类、配置设计和边界情况处理。相比指数退避这类常见算法,工程实践中更大的挑战在于:如何让系统在各种异常场景下都能正确工作。


核心实现

先看整体结构:

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;
  }
}

几个关键设计点:

  1. 循环次数是 maxRetries + 1 - 包括初始请求和所有重试
  2. 保存最后的错误 - 确保所有重试失败后能抛出准确的错误信息
  3. 支持回调机制 - 方便集成日志和监控系统

错误分类:哪些错误应该重试?

这是实现重试机制时的第一个核心问题。不是所有错误都应该重试:

// 应该重试的错误(临时性错误)
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 状态码的情况):

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);
}

请求级别配置

在实际应用中,不同的接口可能需要不同的重试策略。支持全局配置和请求级别配置的覆盖:

// 全局配置
const retryManager = new RetryManager({
  maxRetries: 3,
  retryDelay: 1000,
  exponentialBackoff: true,
});

// 请求级别覆盖全局配置
await retryManager.executeWithRetry(() => fetch("/api/critical"), {
  maxRetries: 5, // 重要接口多重试几次
  retryDelay: 2000,
});

实现方式:

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)时特别重要:

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;
  }
}

在计算延迟时,优先使用服务器的指示:

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):大量客户端同时重试,可能压垮刚恢复的服务器。

使用指数退避 + 随机抖动可以有效缓解这个问题:

// 指数退避:1s, 2s, 4s, 8s...
const exponentialDelay = baseDelay * Math.pow(2, attempt);

// 随机抖动:避免所有客户端同时重试
const jitter = Math.random() * 1000;

return exponentialDelay + jitter;

这样,即使多个客户端同时遇到错误,它们的重试时间也会分散开来。


使用示例

基础用法

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()),
);

不同场景的配置策略

// 读操作:可以多重试
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,
});

集成监控系统

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(),
    });
  },
});

边界情况处理

生产环境中会遇到各种边界情况,需要妥善处理:

空错误对象

private shouldRetry(error: any, ...): boolean {
  if (!error) return true;  // 网络错误,应该重试
  // ...
}

缺少响应对象

const status = error.response?.status || error.status;
if (!status) return true; // 没有状态码 = 网络错误

Retry-After 解析失败

try {
  const date = new Date(retryAfter);
  const delay = date.getTime() - Date.now();
  return delay > 0 ? delay : null; // 负数(过去的时间)返回 null
} catch {
  return null; // 解析失败,降级到指数退避
}

大小写不敏感的头处理

const retryAfter =
  error.response?.headers?.["retry-after"] ||
  error.response?.headers?.["Retry-After"];

测试策略

基础重试测试

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");
});

不重试非可重试错误

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 头测试

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:使用幂等性设计

async function submitOrder(orderData) {
  return await client.post("/api/orders", {
    ...orderData,
    requestId: generateUUID(), // 服务器根据 ID 去重
  });
}

方案 2:限制写操作的重试

const writeRetry = new RetryManager({
  maxRetries: 1, // 只重试一次
  retryableStatuses: [408, 503, 504], // 只重试明确的临时错误
});

避免重试风暴

使用指数退避 + 随机抖动:

const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;

这样可以将重试请求分散到不同的时间点,避免同时压垮服务器。

监控和告警

通过回调机制集成监控:

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 头处理:尊重服务器的指示
  • 请求级配置:支持灵活的配置覆盖
  • 边界情况处理:处理各种异常场景
  • 测试策略:确保代码的可靠性

重试机制看似简单,但要做到生产级别,需要考虑很多细节。希望本文能为你的实践提供参考。


参考资料