> 把所有拒绝都压成 429,等于让客户端在重试永远不可能成功的时候重试

Systems & Infrastructure Beginner 2026-10-09 00:11 2026-10-09
#api-design #rate-limiting #http #error-handling #observability #retry

限流服务拒绝一个请求时,拒绝的原因属于几个本质不同的类别, 把它们全压成 429 Too Many Requests 会同时骗到两方:

结论先行

限流服务拒绝一个请求时,拒绝的原因属于几个本质不同的类别, 把它们全压成 429 Too Many Requests 会同时骗到两方:

  • 骗客户端:告诉它"过会儿再来",而有些情况下再来一万次也不会成功;
  • 骗中间层:把一个"后端满了"的服务端容量问题,报成"客户端超配额"的客户端问题。 代理和监控会据此做出完全相反的处置。

至少要分出这五类:

原因 状态码 含义 Retry-After
token 预算用尽 429 配额问题,会自己补充 ✅ 给出补充时刻
请求数预算用尽 429 同上 ✅
并发与队列都满 503 服务端容量,不是客户端配额 ✅
还有槽位,但排到时已超过调用方 deadline 503 可行性问题 ✅
请求比该租户的全部预算还大 400 永远不可能成功 ❌ 不给

最后一行是关键:给一个 unsatisfiable 的请求发 Retry-After, 就是在主动制造一个无限重试循环。

为什么

这几类在客户端应该做什么上是完全分叉的:

  • 预算用尽 → 等到补充时刻再来,同样的请求会成功。
  • 容量满 → 等,但等多久取决于别人什么时候做完,和自己的配额无关; 而且这时候该做的往往是降级或换区,不是原地重试。
  • deadline 不可行 → 立刻失败比排队更好。admit 它严格更差: 它会占住一个槽位直到排完,然后把响应交给一个已经不在等的调用方。 这是纯粹的浪费,而且是双倍的——既没服务成这个请求,又挤掉了别的。
  • 超过全部预算 → 请求本身要改(拆小、换租户),重试是无效动作。

压成一个码之后,这四种处置在客户端看来一模一样,于是它只会做一件事:退避重试。 对其中三类来说那是错的。

顺带一个观测上的理由

状态码是唯一一个默认会被每一层记录下来的区分信号。 把原因塞进 body 的 reason 字段当然也行,但代理日志、CDN 统计、 云厂商的监控面板都只看状态码。分开之后,"今天是配额不够还是机器不够" 这个问题在现成的面板上就能回答,不用查应用日志。

怎么用

设计任何准入 / 限流 / 配额接口时:

  1. 先列拒绝原因,再映射状态码,不要反过来。 判据是一句话:「客户端收到这个之后,正确的动作是什么?」 动作不同就必须是不同的码(或至少不同的码 + 明确的 Retry-After 语义)。
  2. Retry-After 是一个承诺,不是礼貌用语。给不出确定时刻就别给。 给了却不准,比不给更糟——它会让客户端在精确的错误时刻同时回来。
  3. 4xx / 5xx 的界线按"谁要改"划:客户端改请求 → 4xx; 服务端加容量或等队列 → 5xx。配额用尽是 4xx(429)因为是客户端用超了; 队列满是 5xx 因为客户端什么都没做错。
  4. 把原因同时放进 body 的结构化字段和指标标签。 状态码给中间层看,字段给人看,标签给自己看。

出处

~/Engineering/Rate-Limiter-Service(LLM Admission Gateway)的 shed-reason 表。这个服务同时按 token 预算、请求预算、并发、队列深度和 SLO 可行性 做准入,所以五类拒绝在同一个接口上全都会出现——这也是为什么它必须把它们分开。 相关:预留不加租约,配额会单调归零——而且每一步决策都是对的,没有任何错误可查、最危险的工具故障是"静默少做"——技能在、后端不在。