JsonMind
API 与接口13 分钟阅读更新于 2026年11月11日

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

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

JM作者:JsonMind Engineering最后复核: 2026年11月11日依据权威规范与引用来源复核。
快速答案

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/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 状态码查询,真正的原因则要从响应头和正文里找。

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/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 格式、服务端时钟基准、响应体释放和总截止时间。

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 请求工作台 先复现。

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 转换器 转换,避免引号和头大小写在手工重写时丢失。少发 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。

参考资料

把指南用起来

浏览全部工具

打开浏览器工具。输入内容保留在浏览器中,不会上传到业务服务器。

相关指南

全部指南