重试机制

要点

  • 网络请求失败不意味着「对方不在线」,可能只是暂时的网络波动或下游服务短暂过载
  • 重试机制让客户端在请求失败后自动再试一次或多次,把瞬时故障消化在本地
  • 直接无限重试会带来更大的问题——下游被压垮、用户等太久、资源白白消耗
  • 指数退避(exponential backoff)让每次重试之间的等待时间成倍增长,给下游留出恢复空间
  • 抖动(jitter)在等待时间上加入随机偏移,避免多个客户端同时发起重试形成雪崩
  • 不是所有错误都值得重试——400 参数错误、401 未认证、403 无权限这些客户端错误,重试多少次结果都一样
  • 重试必须配合幂等性设计和超时控制,否则可能造成重复操作或请求堆积

1. 从一个真实的失败场景开始

假设你在做一个 AI 图片生成接口,用户在页面点击「生成」,后端向 AI 服务商发起请求。大部分时候请求正常返回,但偶尔会遇到这种情况:

POST https://api.ai-provider.com/v1/generate
→ 503 Service Unavailable

用户看到报错,再点一次,这次成功了。

这种「再试一次就好了」的场景非常常见。问题在于,如果你把这个接口做成自动化的后端服务,不能靠人工去点重试按钮——需要代码自己处理。

先来看最简单的做法:

// src/lib/simple-retry.ts
async function fetchWithRetry(url: string, options: RequestInit) {
  const MAX_RETRIES = 3
 
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
    try {
      const response = await fetch(url, options)
 
      if (response.ok) {
        return response
      }
 
      console.warn(`Attempt ${attempt + 1} failed: ${response.status}`)
    } catch (error) {
      console.warn(`Attempt ${attempt + 1} threw: ${error}`)
    }
  }
 
  throw new Error(`All ${MAX_RETRIES + 1} attempts failed for ${url}`)
}

这段代码能工作,但有几个问题:

  • 所有重试之间没有等待,连续 4 次请求在几百毫秒内全部打出去
  • 不管什么错误都重试,包括 400、401 这些重试也没用的状态码
  • 下游服务已经过载了,4 个请求瞬间全部砸过去,反而加重负担
  • 如果同时有 100 个用户触发重试,下游会在同一秒收到 400 个请求

这些问题在生产环境里迟早会暴露出来。接下来逐个解决。

2. 哪些错误值得重试

不是所有失败都应该重试。先分清两类错误:

可重试的错误

  • 5xx 服务端错误(500、502、503、504)— 问题出在对方服务器,可能是暂时的
  • 429 Too Many Requests — 触发了限流,对方明确告诉你「稍后再试」
  • 网络连接失败 — DNS 解析失败、TCP 超时、TLS 握手失败,通常是瞬时网络问题

不应该重试的错误

  • 400 Bad Request — 请求参数有误,再发也是一样的结果
  • 401 Unauthorized — 认证失败,需要检查 API Key,不是网络问题
  • 403 Forbidden — 没有权限,重试不会改变权限
  • 404 Not Found — 资源不存在
  • 422 Unprocessable Entity — 请求格式正确但语义有误

判断逻辑很简单:看状态码是不是 5xx 或 429,或者请求是否因为网络异常根本没有拿到响应。

// src/lib/retryable.ts
function isRetryableError(status: number): boolean {
  return status === 429 || (status >= 500 && status <= 599)
}
 
function isNetworkError(error: unknown): boolean {
  // fetch 在网络层面失败时(DNS、超时、连接断开),会抛出 TypeError
  return error instanceof TypeError
}

把判断逻辑单独抽出来,后续扩展或测试都方便。

3. 指数退避:让等待时间逐渐变长

最简单的重试是「失败了立刻再试」。但如果下游服务暂时过载,瞬间发过去一堆请求只会让情况更糟。

指数退避的思路是:每次重试之后,等待时间翻倍。

  • 第 1 次重试:等 1 秒
  • 第 2 次重试:等 2 秒
  • 第 3 次重试:等 4 秒
  • 第 4 次重试:等 8 秒

这样给下游留出恢复的时间窗口,也避免自己的请求长时间占着连接。

// src/lib/exponential-backoff.ts
function getDelay(attempt: number, baseDelay = 1000): number {
  // attempt 从 0 开始,第 1 次重试 attempt=0,等待 baseDelay * 2^0 = 1000ms
  return baseDelay * Math.pow(2, attempt)
}
 
// 验证一下输出
// attempt=0 → 1000ms (1s)
// attempt=1 → 2000ms (2s)
// attempt=2 → 4000ms (4s)
// attempt=3 → 8000ms (8s)
// src/lib/delay.ts
function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

4. 加入抖动,避免雪崩

指数退避解决了一个问题,但引入了另一个:如果多个客户端同时发起请求、同时失败,它们会在同一时刻触发重试,并且在相同的延迟后再次同时重试。

举个例子:100 个 Worker 实例同时调用下游 AI 接口,全部收到 503。按照指数退避,它们都在 1 秒后重试,1 秒后下游收到 100 个请求——又被打崩了。

解决办法是在延迟时间上加一个随机偏移,叫做「抖动」(jitter)。每个客户端的实际等待时间不一样,请求就被分散开了。

一种常用的方式是「等值抖动」(equal jitter):保留一半的指数退避时间,另一半加随机偏移。这样等待时间不会太短,也不会太集中。

// src/lib/jitter.ts
function getDelayWithJitter(attempt: number, baseDelay = 1000): number {
  const exponentialDelay = baseDelay * Math.pow(2, attempt)
  const half = exponentialDelay / 2
  // 前半段固定等待 + 后半段随机
  return half + Math.random() * half
}
 
// attempt=0 → 500~1000ms
// attempt=1 → 1000~2000ms
// attempt=2 → 2000~4000ms

这种方式能有效打散请求,同时保证每次重试都有合理的最低等待时间。

5. 整合成一个完整的重试函数

把前面的判断逻辑、指数退避、抖动组合到一起,再加上最大重试次数限制:

// src/lib/retry.ts
type RetryOptions = {
  maxRetries?: number
  baseDelay?: number
  shouldRetry?: (status: number) => boolean
}
 
const DEFAULT_RETRY_OPTIONS: Required<RetryOptions> = {
  maxRetries: 3,
  baseDelay: 1000,
  shouldRetry: (status) => status === 429 || (status >= 500 && status <= 599),
}
 
async function fetchWithRetry(
  url: string,
  options: RequestInit = {},
  userOptions: RetryOptions = {}
): Promise<Response> {
  const { maxRetries, baseDelay, shouldRetry } = {
    ...DEFAULT_RETRY_OPTIONS,
    ...userOptions,
  }
 
  let lastError: unknown
 
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, options)
 
      // 成功,或者是不应该重试的错误,直接返回
      if (response.ok || !shouldRetry(response.status)) {
        return response
      }
 
      // 如果是最后一次尝试,不再等待,直接返回失败响应
      if (attempt === maxRetries) {
        return response
      }
 
      // 检查 Retry-After 头部(429 响应通常会带),没有则用等值抖动
      const retryAfter = parseRetryAfterHeader(response.headers.get('Retry-After'))
      const delay = retryAfter ?? getDelayWithJitter(attempt, baseDelay)
 
      console.log(`[Retry] ${attempt + 1}/${maxRetries}, status=${response.status}, wait=${delay}ms`)
      await sleep(delay)
    } catch (error) {
      lastError = error
 
      if (attempt === maxRetries) break
 
      const delay = getDelayWithJitter(attempt, baseDelay)
      console.log(`[Retry] ${attempt + 1}/${maxRetries}, network error, wait=${delay}ms`)
      await sleep(delay)
    }
  }
 
  if (lastError instanceof TypeError) throw lastError
  throw new Error(`All ${maxRetries + 1} attempts failed for ${url}`)
}
 
function parseRetryAfterHeader(header: string | null): number | null {
  if (!header) return null
  const seconds = Number(header)
  if (!Number.isNaN(seconds)) return seconds * 1000
  const date = Date.parse(header)
  return Number.isNaN(date) ? null : Math.max(0, date - Date.now())
}
 
function getDelayWithJitter(attempt: number, baseDelay: number): number {
  const exponentialDelay = baseDelay * Math.pow(2, attempt)
  return exponentialDelay / 2 + Math.random() * (exponentialDelay / 2)
}
 
function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms))
}
 
export { fetchWithRetry, type RetryOptions }

有几个细节值得注意:

  • shouldRetry 可以通过参数覆盖,调用方可以根据业务需求调整哪些状态码需要重试
  • 429 响应通常会带 Retry-After 头部,告诉客户端多久之后可以重试,优先使用这个值
  • 最后一次尝试不再等待,直接返回结果,避免无意义的 sleep
  • 网络错误和 HTTP 错误走不同的分支,但重试逻辑是一样的

6. 在 Hono 路由中使用

把这个重试函数接到实际的 AI API 调用里。假设你在做一个图片生成接口:

// src/routes/image-generate.ts
import { Hono } from 'hono'
import type { AppEnv } from '../types'
import { fetchWithRetry } from '../lib/retry'
 
export const imageGenerateApp = new Hono<AppEnv>()
 
imageGenerateApp.post('/api/image/generate', async (c) => {
  const body = await c.req.json<{ prompt: string }>()
  const { prompt } = body
 
  if (!prompt || typeof prompt !== 'string') {
    return c.json({ error: 'prompt is required' }, 400)
  }
 
  try {
    const response = await fetchWithRetry(
      'https://api.ai-provider.com/v1/generate',
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${c.env.AI_API_KEY}`,
        },
        body: JSON.stringify({
          prompt,
          model: 'stable-diffusion-xl',
          size: '1024x1024',
        }),
      },
      {
        maxRetries: 3,
        baseDelay: 2000, // AI 接口响应慢,基础延迟设大一些
      }
    )
 
    if (!response.ok) {
      const errorBody = await response.text()
      return c.json(
        { error: 'AI service error', detail: errorBody },
        response.status as 500
      )
    }
 
    const data = await response.json()
    return c.json({ imageUrl: data.url })
  } catch (error) {
    console.error('Image generation failed after all retries:', error)
    return c.json({ error: 'Service temporarily unavailable' }, 503)
  }
})

fetchWithRetry 的用法和普通 fetch 一样,只是多传了一个配置对象。路由代码不需要关心重试逻辑的细节。

7. 幂等性:重试不等于可以随便重试

重试机制有一个前提条件:被重试的操作必须是幂等的

幂等的意思是,同一个操作执行一次和执行多次,产生的效果完全一样。

幂等的操作,可以安全重试

  • GET 请求 — 读取数据,不会改变服务器状态
  • PUT 请求 — 用指定内容覆盖资源,多次覆盖结果一样
  • DELETE 请求 — 删除资源,第一次删除后资源已经不存在,后续删除返回 404,没有副作用

不幂等的操作,重试需要格外小心

  • POST 创建资源 — 每次请求可能创建一条新记录。如果服务器实际创建成功了,但响应超时了,客户端以为失败又发一次,就会出现重复数据
  • POST 扣款、下单、发送消息 — 同理,重复执行会造成重复扣款或重复消息

对于不幂等的 POST 请求,常见的处理方式有两种:

方式一:在请求中携带幂等键。让服务端能识别出这是同一个请求的重试,而不是新请求。

// src/lib/idempotent-fetch.ts
async function createOrderWithRetry(
  orderData: OrderPayload,
  idempotencyKey: string
) {
  return fetchWithRetry('https://api.order-service.com/orders', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      // 服务端用这个 key 判断是否是重复请求
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify(orderData),
  })
}
 
// 调用方生成一个唯一的幂等键
const idempotencyKey = crypto.randomUUID()
await createOrderWithRetry(orderData, idempotencyKey)

服务端收到相同的 Idempotency-Key,应该返回第一次请求的结果,而不是重新创建订单。

方式二:只重试确定没有到达服务端的错误。网络错误(TypeError)说明请求可能没有到达服务端,可以重试。但如果拿到了响应(即使是 500),说明服务端已经收到了请求,后续处理需要更谨慎——这种情况下只重试网络异常,拿到 HTTP 响应就直接返回。

在实际项目中,AI 接口大多是「根据输入生成内容」,同一个 prompt 生成两次不会造成数据重复或状态冲突。所以 AI 接口的重试风险相对可控。但涉及支付、订单、通知类操作,幂等性必须认真处理。

8. 重试与超时的配合

重试和超时是两个独立的机制,但需要协同工作。

一个常见的错误是:给每次请求设了 30 秒超时,重试 3 次,最坏情况下用户要等 4 × 30 = 120 秒。这对用户来说太久了。

更好的做法是给整个重试流程设一个总超时,超过总时间就不再重试:

// src/lib/retry-with-timeout.ts
async function fetchWithRetryAndTimeout(
  url: string,
  options: RequestInit = {},
  totalTimeoutMs = 30000,
  retryOptions: RetryOptions = {}
): Promise<Response> {
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), totalTimeoutMs)
 
  try {
    return await fetchWithRetry(url, {
      ...options,
      signal: controller.signal,
    }, retryOptions)
  } finally {
    clearTimeout(timer)
  }
}

这里有个细节:AbortControllersignal 一旦触发 abort,后续所有重试都会被取消。这通常是我们想要的效果——总超时到了就停止。

如果你希望每次请求有独立的超时(比如每次请求最多 10 秒),同时整体有总超时(比如最多 30 秒),需要给每次 fetch 调用创建独立的 AbortController。可以把超时逻辑封装到一个单独的函数里,替换 fetchWithRetry 内部的 fetch 调用。

在 Cloudflare Workers 中还需要注意 Worker 的 CPU 时间限制。免费版 Worker 的 CPU 时间上限是 10ms,付费版是 30s。重试逻辑中的 await sleep() 不算 CPU 时间,但实际的 fetch 请求会占用。确保总耗时不超过 Worker 的限制。

9. 重试的可观测性

重试机制上线之后,最容易被忽略的问题是:你怎么知道它在正常工作?

如果只是 console.log 打几行日志,在线上出了问题很难排查。建议至少记录以下信息:

  • 请求的 URL
  • 当前是第几次重试
  • 失败的状态码或错误信息
  • 本次等待了多久
  • 时间戳
// src/lib/retry-logger.ts
function logRetryAttempt(entry: {
  url: string
  attempt: number
  maxRetries: number
  status?: number
  delayMs: number
}): void {
  console.log(JSON.stringify({
    level: 'warn',
    event: 'retry_attempt',
    ...entry,
    timestamp: new Date().toISOString(),
  }))
}

在 Cloudflare Workers 中,这些日志可以通过 Workers Analytics Engine 或 Logpush 收集。基于这些数据可以设置告警:

  • 某个接口的重试率突然升高 → 下游服务可能出了问题
  • 大量请求触发了最大重试次数 → 下游可能已经持续不可用
  • 重试延迟普遍偏长 → 下游可能处于半故障状态

总结

回顾这篇的内容:

  • 重试机制的本质是在瞬时故障发生时,由客户端自动消化失败,而不是直接把错误抛给用户
  • 指数退避让每次重试的间隔成倍增长,给下游留出恢复时间
  • 抖动在等待时间上加入随机性,避免多个客户端同时重试造成雪崩
  • 只对可重试的错误(5xx、429、网络异常)进行重试,客户端错误直接返回
  • 不幂等的操作(POST 创建资源、扣款等)需要通过幂等键或限制重试范围来保证安全
  • 重试和超时需要配合使用,避免总耗时失控
  • 上线后需要可观测性支持,通过日志和告警监控重试行为

下一篇会讨论如何在 AI API 服务中做请求限流,保护后端不被突发流量打垮。

所属分组

12-Hono开发AI API服务