> 把所有拒绝都压成 429,等于让客户端在重试永远不可能成功的时候重试
限流服务拒绝一个请求时,拒绝的原因属于几个本质不同的类别, 把它们全压成 429 Too Many Requests 会同时骗到两方:
// TABLE_OF_CONTENTS
结论先行¶
限流服务拒绝一个请求时,拒绝的原因属于几个本质不同的类别,
把它们全压成 429 Too Many Requests 会同时骗到两方:
- 骗客户端:告诉它"过会儿再来",而有些情况下再来一万次也不会成功;
- 骗中间层:把一个"后端满了"的服务端容量问题,报成"客户端超配额"的客户端问题。 代理和监控会据此做出完全相反的处置。
至少要分出这五类:
| 原因 | 状态码 | 含义 | Retry-After |
|---|---|---|---|
| token 预算用尽 | 429 | 配额问题,会自己补充 | ✅ 给出补充时刻 |
| 请求数预算用尽 | 429 | 同上 | ✅ |
| 并发与队列都满 | 503 | 服务端容量,不是客户端配额 | ✅ |
| 还有槽位,但排到时已超过调用方 deadline | 503 | 可行性问题 | ✅ |
| 请求比该租户的全部预算还大 | 400 | 永远不可能成功 | ❌ 不给 |
最后一行是关键:给一个 unsatisfiable 的请求发 Retry-After,
就是在主动制造一个无限重试循环。
为什么¶
这几类在客户端应该做什么上是完全分叉的:
- 预算用尽 → 等到补充时刻再来,同样的请求会成功。
- 容量满 → 等,但等多久取决于别人什么时候做完,和自己的配额无关; 而且这时候该做的往往是降级或换区,不是原地重试。
- deadline 不可行 → 立刻失败比排队更好。admit 它严格更差: 它会占住一个槽位直到排完,然后把响应交给一个已经不在等的调用方。 这是纯粹的浪费,而且是双倍的——既没服务成这个请求,又挤掉了别的。
- 超过全部预算 → 请求本身要改(拆小、换租户),重试是无效动作。
压成一个码之后,这四种处置在客户端看来一模一样,于是它只会做一件事:退避重试。 对其中三类来说那是错的。
顺带一个观测上的理由¶
状态码是唯一一个默认会被每一层记录下来的区分信号。
把原因塞进 body 的 reason 字段当然也行,但代理日志、CDN 统计、
云厂商的监控面板都只看状态码。分开之后,"今天是配额不够还是机器不够"
这个问题在现成的面板上就能回答,不用查应用日志。
怎么用¶
设计任何准入 / 限流 / 配额接口时:
- 先列拒绝原因,再映射状态码,不要反过来。
判据是一句话:「客户端收到这个之后,正确的动作是什么?」
动作不同就必须是不同的码(或至少不同的码 + 明确的
Retry-After语义)。 Retry-After是一个承诺,不是礼貌用语。给不出确定时刻就别给。 给了却不准,比不给更糟——它会让客户端在精确的错误时刻同时回来。- 4xx / 5xx 的界线按"谁要改"划:客户端改请求 → 4xx; 服务端加容量或等队列 → 5xx。配额用尽是 4xx(429)因为是客户端用超了; 队列满是 5xx 因为客户端什么都没做错。
- 把原因同时放进 body 的结构化字段和指标标签。 状态码给中间层看,字段给人看,标签给自己看。
出处¶
~/Engineering/Rate-Limiter-Service(LLM Admission Gateway)的
shed-reason 表。这个服务同时按 token 预算、请求预算、并发、队列深度和 SLO 可行性
做准入,所以五类拒绝在同一个接口上全都会出现——这也是为什么它必须把它们分开。
相关:预留不加租约,配额会单调归零——而且每一步决策都是对的,没有任何错误可查、最危险的工具故障是"静默少做"——技能在、后端不在。