# HTTP 429 Too Many Requests：Retry-After、指数退避与重试风暴

> 讲清 429 的真实含义、Retry-After 的秒数与 HTTP 日期两种写法、带抖动的指数退避、幂等重试，以及限流、配额、并发限制和 CDN/WAF 429 的排查方法。

**快速答案:** 429 表示服务端看懂了请求，但因为限流、配额或并发限制而拒绝处理，并不是认证失败或服务器故障。有 Retry-After 就按它等待，没有就用带上限和抖动的指数退避；只重试幂等请求，POST 必须带幂等键，并先确认 429 来自源站、网关还是 CDN。

## 核心要点

- 429 是策略性拒绝：请求本身没坏，不要当成 400 或 500 处理。
- Retry-After 可能是秒数，也可能是 HTTP 日期；日期要和响应里的 Date 头比较，并对结果做上下限裁剪。
- 限流管短时间窗口，配额管长周期额度，并发限制管同时在途的请求，熔断则是客户端对故障的快速失败策略。
- 全抖动指数退避、单次上限、总截止时间和重试预算一起用，才能避免所有客户端在同一秒一起重试。
- POST 只有带幂等键才能自动重试；改代码前先确认 429 是源站、API 网关还是 CDN/WAF 返回的。

---

`429 Too Many Requests` 是一个容易被误判的状态码：请求格式通常没问题，密钥也可能有效，服务本身更是健康的，只是某个策略层认为这个客户端已经用完了当前允许的额度，必须等待。于是很多人把它当成偶发故障，立刻重试，结果把一次短暂的限流变成一场重试风暴。

这篇文章讲清楚 429 的语义、Retry-After 的两种格式、限流与配额和并发限制的区别，以及一套在大量客户端同时收到 429 时仍然稳定的重试算法。

## 429 到底在表达什么

429 请求过多（Too Many Requests）由 RFC 6585 补充定义，含义是客户端在给定时间内发送了过多请求。服务端理解了请求，但因为触碰限制而选择不处理。这个区别很重要：

- `400` 表示请求格式有问题，原样重发没有意义。
- `401` 或 `403` 表示身份或权限不对，应该修凭据或授权范围。
- `503` 表示服务当前无法处理，可能是过载或维护，也可能带 `Retry-After`。
- `429` 表示存在一条限制策略，而这次请求超了。

限制可能是每秒请求数、每天请求数、并发请求数、计算成本，或者边缘安全规则。状态码本身不会告诉你属于哪一种。RFC 6585 建议响应正文说明原因，但不是强制要求，很多 CDN 直接返回 HTML 页面而不是 JSON。

```http
HTTP/1.1 429 Too Many Requests
Date: Wed, 11 Nov 2026 07:29:00 GMT
Content-Type: application/problem+json
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1794382170
Cache-Control: no-store
X-Request-Id: req_01JCH6X8Y4Q2V7M3N9K5R1T6B8

{
  "type": "https://api.example.com/problems/rate-limited",
  "title": "Too Many Requests",
  "status": 429,
  "code": "rate_limit_exceeded",
  "detail": "100 requests per 60 seconds exceeded for key acct_8241.",
  "retryAfter": 30
}
```

状态行只确认了错误类别；`Retry-After: 30` 才是响应里唯一有标准含义的重试信号。`X-RateLimit-*` 和 JSON 正文都是厂商约定，换个 API 单位和字段就可能变样。

429 在原则上可以重试，但不是无条件重试。等待时间可能只有几百毫秒，也可能要等到下个计费周期。需要标准语义时可以先查 [HTTP 状态码查询](/tools/http-status)，真正的原因则要从响应头和正文里找。

## Retry-After：秒数还是 HTTP 日期

RFC 9110 规定 Retry-After 响应头有两种取值：

- `Retry-After: 120` 表示从响应生成时刻起等待 120 秒。
- `Retry-After: Wed, 11 Nov 2026 07:30:00 GMT` 表示在某个绝对时间之前不要重试。

服务端应优先发送上面这种 IMF-fixdate 格式。HTTP 解析器还要兼容早期的 RFC 850 和 asctime 格式，但新服务不应再生成它们。

```http
HTTP/1.1 429 Too Many Requests
Date: Wed, 11 Nov 2026 07:29:00 GMT
Retry-After: Wed, 11 Nov 2026 07:30:00 GMT
Content-Type: application/json
RateLimit: "default";r=0;t=60
RateLimit-Policy: "default";q=100;w=60
Cache-Control: no-store

{"error":"rate_limited","message":"Retry after the date in Retry-After."}
```

第二种格式最容易写出 bug。不要拿用户本机时钟去比较，而要以响应里的 `Date` 头为基准，计算 `delay = max(0, Date.parse(retryAfter) - Date.parse(date))`。如果缺少 `Date`，只能退回本机时钟，并接受时钟偏差带来的等待误差。日期已经过去时按 0 处理，但仍应加一点抖动，避免所有客户端在同一毫秒重试。

把 `Retry-After` 当成建议而不是承诺：计时结束时限制可能仍然存在，下一次请求还是可能收到 429。响应没有这个头时，不要凭空假设“等一秒”，而应使用指数退避；有这个头时，把它当作等待下限并叠加抖动。值特别大时，例如配额要到六小时后才重置，不要把工作线程阻塞六小时，而应把任务落盘后重新调度，或者直接给用户一个明确的提示。

单位错误非常常见：`Retry-After` 是秒不是毫秒；厂商的 `X-RateLimit-Reset` 可能是 epoch 秒（GitHub）、相对秒数，也可能是毫秒。必须逐个 API 查文档。

## 限流、配额、并发限制与熔断的区别

这四种机制都会让人“慢下来”，但恢复的时间尺度完全不同。

| 机制 | 限制什么 | 典型周期 | 429 的含义 | 正确处理 |
| --- | --- | --- | --- | --- |
| 限流 | 每个键的请求数或成本 | 秒或分钟 | 短窗口预算耗尽 | 等重置，遵守 Retry-After，加抖动 |
| 配额 | 总用量或总花费 | 天、月或套餐周期 | 长周期额度用完 | 等重置或升级套餐，立刻重试没有意义 |
| 并发限制 | 同时在途的请求数 | 瞬时 | 在途请求太多 | 降低并发，等槽位释放再试 |
| 熔断 | 对不健康依赖的调用 | 到下一次探测 | 根本不是服务端限制 | 快速失败，稍后探测，不要继续打 |

限流通常是令牌桶：桶有容量，按固定速率补充。它允许短时突发，然后在秒或分钟级窗口内平滑。固定窗口计数器在窗口边界会放过两倍流量，例如 59.9 秒发满一轮、60.1 秒再发一轮，所以更稳的做法是滑动窗口、滑动日志或 GCRA。

配额是长周期额度：每天请求数、每月 token 数、套餐包含的调用量。配额耗尽后等 30 秒毫无意义，重试只会浪费资源。如果 API 用 429 表达配额耗尽，正文通常会有 `quota_exceeded` 之类的错误码，重置时间可能在几小时后。还要区分“套餐上限，需要升级”和“本周期额度，等待重置”。

并发限制约束同时在途的请求数。只要有一个在途请求结束，429 就可能消失，所以正确的做法是降低并发、排队，而不是按请求次数做很长的指数退避。

熔断不是服务端限制，而是客户端或代理的保护模式：当错误率或延迟超过阈值时打开断路器，快速失败，过一段时间再半开探测。429 可以作为熔断的输入，但普通限流不应该被当成“服务宕机”，否则一次窗口抖动就会让整个依赖被切断。最好把 5xx/超时和 429 分开统计，并按 host、API key 建不同熔断器。

## 安全地解析 429 响应

状态码告诉你错误类别，响应头和正文才告诉你该怎么做。解析时要防御性处理：

- 先判断 `response.status === 429`，不要用正文里是否出现“rate limit”来猜。
- 响应头名称大小写不敏感，用 `response.headers.get("retry-after")`，不要用对象下标。
- 把头值当作不可信输入：能解析成数字才用，`NaN` 丢弃，结果裁剪到 `[0, cap]`。Node 的 `setTimeout` 对超过 2,147,483,647 毫秒（约 24.8 天）的延迟会按 1 毫秒处理，配额重置时间很容易踩到这个坑。
- `Headers.get()` 会把重复的头用逗号拼起来，先按逗号拆分再取第一个有效值。
- 解析正文前看 `Content-Type`。CDN 可能返回 HTML 错误页，直接 `JSON.parse` 会抛异常；读取正文要限制大小，例如 64 KB。
- 收集请求标识：`x-request-id`、`cf-ray`、`x-amzn-requestid`、`x-envoy-ratelimited`、`server`、`via`、`x-cache`，它们能指出是哪一层返回的 429。
- 只在文档明确时读取限流头。`X-RateLimit-*` 是约定不是标准，`RateLimit` 与 `RateLimit-Policy` 来自 IETF 草案，值可能是整数、epoch 秒、`6m0s` 这样的时长，或者结构化字段。
- 日志里不要出现 API key 或 Cookie；需要关联时对限流键做哈希。
- 如果要重试，先调用 `response.body?.cancel()` 释放响应体，否则重试循环可能占满连接池。

## 带抖动的指数退避与上限

最朴素的退避是 `base × 2^attempt`。如果所有客户端都在同一时刻收到 429，又没有抖动，它们会在同一时刻一起重试：一万个客户端都等两秒，第二秒整点又同时打到 API，限流器再次全部拒绝，这就是重试风暴。

常见抖动策略：

- 全抖动（full jitter）：`random(0, min(cap, base × 2^attempt))`，分散效果最好，平均等待略长。
- 等量抖动（equal jitter）：一半固定、一半随机，等待更可预测，但分散程度较低。
- 去相关抖动（decorrelated jitter）：`min(cap, random(base, previous × 3))`，适合长期运行的后台任务。

还要加四道边界：

1. 单次延迟上限，例如 30 秒，防止 `2^10` 变成几个小时。
2. 总截止时间，例如 2 分钟，约束整个重试过程而不是每次尝试。
3. 最大尝试次数，例如 5 次；配额耗尽后继续重试基本没有收益。
4. 重试预算，例如重试请求不超过总请求的 10%，避免故障期间流量被重试放大一倍。

有 `Retry-After` 时把它作为下限再加抖动；没有时用全抖动。一个简单有效的规则是：有头时 `delay = min(cap, retryAfter + random(0, 1s))`，无头时 `delay = random(0, min(cap, base × 2^attempt))`。

下面的算法同时处理幂等判断、两种 Retry-After 格式、服务端时钟基准、响应体释放和总截止时间。

```ts
const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);

function parseRetryAfter(response: Response, capMs: number): number | null {
  const raw = response.headers.get("retry-after")?.split(",")[0]?.trim();
  if (!raw) return null;

  let ms: number;
  if (/^\d+$/.test(raw)) {
    ms = Number(raw) * 1000; // 单位是秒，不是毫秒
  } else {
    const target = Date.parse(raw);
    if (Number.isNaN(target)) return null; // 格式不认识，退回指数退避
    const serverNow = Date.parse(response.headers.get("date") ?? "") || Date.now();
    ms = target - serverNow; // 用服务端时钟，抵消客户端偏差
  }

  return Math.min(Math.max(ms, 0), capMs);
}

async function fetchWithRetry(
  url: string,
  init: RequestInit = {},
  opts = { attempts: 5, baseMs: 500, capMs: 30_000, deadlineMs: 120_000 }
): Promise<Response> {
  const headers = new Headers(init.headers);
  const method = (init.method ?? "GET").toUpperCase();
  const canRepeat = IDEMPOTENT_METHODS.has(method) || headers.has("idempotency-key");
  const started = Date.now();

  for (let attempt = 0; ; attempt += 1) {
    const response = await fetch(url, init);
    const retryable = response.status === 429 || response.status === 503;
    if (!retryable || !canRepeat || attempt >= opts.attempts) return response;

    const serverDelay = parseRetryAfter(response, opts.capMs);
    const backoff = Math.min(opts.capMs, opts.baseMs * 2 ** attempt);
    const fullJitter = Math.random() * backoff;
    const delay =
      serverDelay == null
        ? fullJitter
        : Math.min(opts.capMs, serverDelay + Math.random() * 1000);

    if (Date.now() - started + delay > opts.deadlineMs) return response;

    await response.body?.cancel(); // 睡眠前释放连接
    await new Promise((resolve) => setTimeout(resolve, delay));
  }
}
```

函数返回最后一次 `Response` 而不是直接抛异常，调用方仍然能读到错误正文。生产环境还应按 API key 共享退避状态并执行重试预算，这些逻辑放在核心函数外面更容易维护。

## 幂等性与重试安全

HTTP 429 重试是否安全，取决于操作能否重复执行。`GET` 和 `HEAD` 是安全方法，不改服务端状态，重试没有问题。`PUT` 和 `DELETE` 按定义是幂等的，重复执行同一请求应得到同一状态。`POST` 和大多数 `PATCH` 不是：重试 `POST /orders` 可能创建两笔订单，重试 `POST /payments` 可能扣两次款。

429 通常发生在请求被处理之前，所以很多人觉得重试 POST 没关系。不要依赖这个假设：429 可能来自已经计过数的边缘层，也可能客户端在部分处理后才超时。唯一安全的规则是让操作本身可以重复执行。

使用 `Idempotency-Key` 头：为每个逻辑操作生成一个 UUID，所有重试复用同一个键，服务端把键和最终状态、响应正文保存一段时间（常见是 24 小时）。同一个键配不同请求体时应拒绝，通常返回 `422` 或 `409`；并发使用同一个键时，可以返回冲突或第一次的结果。Stripe 让这个模式流行起来，IETF 也已有对应草案。

如果服务端不支持幂等键，就不要自动重试非幂等请求。把失败暴露出来，让用户或对账任务确认操作是否成功。

还有一个容易忽略的情况：重试返回 `409 Conflict` 可能表示第一次请求已经成功、键仍在处理中。把 409 一律当失败，可能导致再次重试，或者给一笔已成功的订单显示错误。

## 服务端与客户端各自该做什么

**如果你在写 API**，做 API 限流时最重要的决定是限流键。按 IP 限流实现简单，但企业 NAT 或移动运营商可能把几千个用户放在一个出口 IP 后面，一个吵闹的客户端就会拖累所有人。优先按账号或 API key 限流，把 IP 限流只当作防滥用兜底。还要按成本给接口加权：一次 GraphQL 查询或批量导出，不应该和健康检查算同样的配额。

用令牌桶或 GCRA，而不是固定窗口计数器。容量 100、每分钟补充 100 的桶允许短时突发，之后按补充速率平滑；固定窗口则会在边界放过 200 个请求。在执行昂贵逻辑之前就返回 429，`Retry-After` 按真实重置时间计算，正文给出稳定的机器可读错误码，例如 `rate_limit_exceeded` 与 `quota_exceeded` 要分开。加上 `Cache-Control: no-store`，避免中间层缓存这条拒绝响应。不要用 429 表达凭据无效（401）、无权限（403）或参数校验失败（422）。

**如果你在调用 API**，要在服务端限流之前先限制自己。按公开限额的 80% 设置客户端令牌桶，有 `Remaining` 头时据此主动降速，可以消掉大部分 429。确保只有一层负责重试：如果应用、SDK、HTTP 客户端和网关都在重试，5 次尝试会变成 5×5×5 次。按 API key 维护一个共享闸门，避免 100 个 worker 各自睡眠、各自重试。交互式请求应快速失败并给出明确提示；后台任务则排队，在总截止时间内重试。需要确认某个方法和请求头组合的实际行为时，可以用 [HTTP 请求工作台](/tools/http-request) 先复现。

## CDN、WAF 和网关产生的 429

不是所有 429 都来自你的应用。请求可能在到达源站之前就被 Cloudflare、AWS API Gateway、Envoy 限流过滤器或 nginx 拒绝：

- Cloudflare 的速率限制规则返回 429，通常会带 `server: cloudflare` 和 `cf-ray`。部分机器人防护返回 HTML 页面而不是 JSON，也不一定带 `Retry-After`。
- AWS API Gateway 的节流返回 429，带 `x-amzn-RequestId`，某些错误还会带 `x-amzn-ErrorType: TooManyRequestsException`。
- Envoy 的本地限流过滤器会加 `x-envoy-ratelimited: true`，并可能附带 `Retry-After`。
- nginx 的 `limit_req` 默认返回 503；只有配置了 `limit_req_status 429` 才会变成 429。所以一条没有应用日志的 503，也可能其实是限流。
- HAProxy、Kong 等网关各有默认行为，先确认响应归哪个组件管。

这对排查很关键：源站访问日志可能什么都没有，“API 返回了 429”这个判断本身就是错的。用请求 ID 对照边缘日志、负载均衡日志和应用日志，确认限流键是 IP、账号、路径还是 WAF 规则，以及它属于安全策略还是用量限制。共享 NAT 会让按 IP 的边缘规则误伤无关用户。

还要检查缓存。如果 429 被 CDN 缓存，限制解除后其他客户端仍可能拿到这条旧响应。源站和边缘都应给 429 加上 `Cache-Control: no-store`。

## 可观测性：Retry-After 是标准，X-RateLimit 不是

`Retry-After` 由 HTTP 规范定义，含义稳定。`X-RateLimit-Limit`、`X-RateLimit-Remaining`、`X-RateLimit-Reset` 只是约定，没有统一语义：reset 可能是 epoch 秒、相对秒数或时长；limit 可能统计请求数、token 数或成本。GitHub 用 epoch 秒的 `x-ratelimit-reset`，并额外提供 `x-ratelimit-resource`；OpenAI 的 `x-ratelimit-reset-requests` 则是 `1s`、`6m0s` 这样的时长字符串。IETF 的 `RateLimit` 和 `RateLimit-Policy` 仍是草案，字段跨版本改过。只解析目标厂商明确文档化的头，并按 host 做能力探测，不要写一个“通用”解析器。

至少埋这些指标：

- 按路由、限流键类型和来源层（边缘或源站）统计的 429 数量。
- 每个逻辑请求的重试次数、实际等待时间、重试最终是否成功。
- `Retry-After` 的分布（p50、p95、最大值），包括缺失比例。
- 重试预算消耗和后台任务队列深度。
- API 暴露时的剩余额度和重置时间。

日志记录尝试序号、原始 `Retry-After`、计算出的延迟、请求 ID 和边缘 ray ID。不要记录 API key；需要关联时对限流键做哈希。链路追踪里把每次重试记成同一个 span 上的事件，这样一条 trace 能看到完整的退避过程，而不是五次互不相关的请求。告警应关注持续的 429 比例和“重试量增速超过请求量增速”，单条 429 只是噪声，比例持续上升才说明风暴正在形成。

## 排查流程与常见错误

按顺序走完这些步骤：

1. 确认是谁返回的。看 `server`、`cf-ray`、`x-amzn-RequestId`、`x-envoy-ratelimited`、`via` 和正文类型。源站日志里找不到对应请求 ID，说明请求在边缘就被拒绝了。
2. 在应用外复现。用 curl 发送同样的方法、URL、请求头和正文。如果请求是从浏览器复制的，用 [cURL 转换器](/tools/curl-converter) 转换，避免引号和头大小写在手工重写时丢失。少发 `Authorization` 或幂等键，测到的就是另一个请求。
3. 判断限制类型。读正文错误码和文档化的响应头：短窗口限流、配额、并发限制还是安全规则。这决定修复方式是等待、降低并发、升级套餐，还是修改 WAF 规则。
4. 确认影响范围。是一个 API key、一个 IP、一个路由，还是所有客户端？换一个 key、换一个网络对比。单 key 的 429 通常是用量问题，所有 key 都 429 往往是故障或限额配置错误。
5. 检查时间。把 `Retry-After` 和 `Date` 对比，确认 reset 值的单位是秒还是毫秒，并观察是否在整分钟边界出现突发。客户端时钟偏差会让日期格式的 `Retry-After` 看起来已经过期。
6. 检查重试行为。统计每个逻辑请求的尝试次数，列出所有会重试的层，确认抖动是否真的随机。固定延迟或共享的重试定时器会重新制造同一波洪峰。
7. 修正确的层。客户端：本地限流、抖动、预算、幂等；服务端：限流键、响应头、配额提示；边缘：规则范围和缓存策略。不要用应用层重试去“修”一个 CDN 返回的 429。
8. 验证。观察 429 比例、重试成功率、p95 延迟和重复写入率。429 降了但 5xx 升了，说明只是把瓶颈挪走了，并没有真正修好。

常见错误：

- 立刻重试，或者固定等一秒再重试。
- 精确等待 `Retry-After` 却不加抖动，也没有共享闸门。
- 把 `Retry-After: 30` 当成 30 毫秒。
- 解析人类可读的错误文案，而不是状态码和响应头。
- POST 没有幂等键也自动重试。
- 配额已经耗尽，还每隔几秒重试到下个月。
- 重试所有 4xx，包括原样重发永远不会成功的 400 和 422。
- 应用、SDK、网关三层同时重试。
- 没有次数上限和总截止时间，无限重试。
- 假设所有 API 的 `X-RateLimit-Reset` 单位一致。
- 用 429 表示认证失败，或者明明知道重置时间却不返回 `Retry-After`。
- 缓存 429 响应，限制解除后还在返回旧结果。

状态码只是第一个信号。等待多久、影响范围多大、是谁返回的，才决定重试是帮忙还是让故障更糟。

## 常见问题

### 收到 429 就应该重试吗？

通常可以，但要先等过服务端要求的延迟，并确认这个操作可以重复执行。没有 Retry-After 时不要立刻重试，应使用带上限、总截止时间和抖动的指数退避；配额耗尽时则应等到额度重置。

### Retry-After 的秒数和 HTTP 日期有什么区别？

像 30 这样的值是相对秒数，从响应生成时开始计算；像 Wed, 11 Nov 2026 07:30:00 GMT 这样的值是绝对时间。计算等待时间时要用响应里的 Date 头做基准，而不是本机时钟，否则客户端时钟偏差会让等待过短或过长。

### X-RateLimit 系列响应头是标准吗？

不是。X-RateLimit-Limit、X-RateLimit-Remaining、X-RateLimit-Reset 只是事实约定，不同厂商对 reset 的单位和含义并不一致。IETF 的 RateLimit 与 RateLimit-Policy 仍是草案，字段也改过版本；只解析目标 API 明确文档化的头。

### POST 遇到 429 怎么重试才不会重复下单？

为每个逻辑操作生成一个 Idempotency-Key，重试时复用同一个键，服务端把键和最终响应保存一段时间。没有服务端幂等支持时，不要自动重试 POST，应把失败暴露出来，让用户或对账任务确认结果。

### 为什么应用日志里看不到 429？

CDN、WAF、API 网关或反向代理可能在请求到达源站前就拒绝了。检查 Server、cf-ray、x-amzn-RequestId、x-envoy-ratelimited 等响应头，并对照边缘节点或负载均衡日志里的请求 ID。

## 参考资料

- [RFC 9110：HTTP 语义](https://www.rfc-editor.org/rfc/rfc9110)
- [MDN：429 Too Many Requests](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Reference/Status/429)
- [IETF 草案：HTTP 的 RateLimit 响应头字段](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/)
- [Stripe 文档：Rate limits](https://docs.stripe.com/rate-limits)

---

- Canonical: https://www.jsonmind.com/zh-CN/guides/http-429-retry-after
- Locale: zh-CN
- Published: 2026-11-11
- Updated: 2026-11-11
- Tools: http-status, http-request, curl-converter