一次 429 引发的三倍流量
「回声」的私信回复在晚高峰接入了三家供应商:A 承担六成流量,B 三成,C 一成。某天 A 在华东区域开始返回 429,网关的判断很简单:429 是可重试错误,换一家再发。
十分钟后,B 和 C 也开始返回 429。值班同学把流量图拉出来,发现原始请求只有每分钟 600 次,实际打出去的尝试却是 1023 次。多出来的 423 次不是新用户,而是重试。A 的故障没有把请求打挂,重试把它们放大后,把三家供应商一起打挂了。
卷一 10.2 已经说过,降级不是“再找一个模型”,而是一条有预算、有顺序的链。到了规模化阶段,这句话还要加一个前提:备用供应商也有自己的配额,而且它不知道你把它当备胎。限流与分流要解决的不是“哪家还能用”,而是“在每家都不够用时,怎样让放大倍数可控”。
文中比例、请求数与成本,除非标注为示例样本上的运行结果,都是案例假设值,用来演示判断过程。
429 不是普通失败
把 429 和 500 放在同一个重试分支,是成本失控的起点。500 表示这次调用坏了,换一次可能成功;429 表示你已经超过了对方允许的速率,立刻重试只会继续超。两者的正确反应完全不同。
429 至少要读出三个信息:多久之后可以再试、当前窗口还剩多少额度、这次超限是请求数还是 token 数。前两个通常来自 Retry-After 与响应头,第三个要结合自己的请求画像判断。拿不到 Retry-After 时,用基准冷却,但必须设上限;否则对方给一个异常大的值,供应商会被冻结到天亮。
冷却不是“暂停整个供应商”,而是“在这一段时间内不再准入”。已经发出去的请求仍然要正常收尾,冷却结束后自动恢复。把冷却写成永久熔断,会把一次分钟级限流放大成小时级不可用。
三种放大,乘起来很快
重试的放大不是加法,是乘法。单请求最多尝试 maxAttempts 次,这是第一层上限;跨供应商重试会把失败流量倒给下一家,这是第二层;如果下一家也 429,而重试策略没有全局预算,失败会继续在供应商之间弹,这是第三层。
| 放大来源 | 触发条件 | 上限来自哪里 | 失控表现 |
|---|---|---|---|
| 单请求重试 | 任意可重试错误 | maxAttempts | 一次用户请求打三次 |
| 跨供应商转移 | 首选供应商 429 | 备用供应商配额 | 备胎比主供应商先挂 |
| 重试风暴 | 多家同时 429 | 全局重试预算 | 尝试数随故障时长持续上涨 |
全局重试预算按原始请求数计算,而不是按失败数计算。示例里 600 条原始请求、10% 预算,意味着整批最多 60 次重试;不管失败是 30 条还是 300 条,重试总量都不会超过 60。按失败数计算则相反:故障越严重,允许的重试越多,正好在最不该加流量的时刻加流量。
配额池:剩余量要逐家相加
供应商配额至少有四个维度:每分钟请求数、每分钟 token 数、同时在途请求数、健康状态。四个维度要分别检查,不能用一个“剩余额度”概括。一个供应商可能请求数还有富余,但 token 已经打满;也可能 token 够,但在途连接已经到顶。
配额池不是把所有配额加成一个总数再平均分。正确的是逐家维护窗口用量,准入时检查目标供应商自己的剩余量;池子的总剩余量只是各家剩余量之和,用于判断“这一分钟还接不接得下”,不能反过来替代逐家检查。
冷却也必须进入配额视图。一个正在冷却的供应商,剩余量应该按零算,而不是按窗口额度算。否则池子会显示“还有额度”,调度却一家都发不出去。
分流顺序必须可复现
多供应商分流常用随机数或轮询。随机数的问题不是不公平,而是不可复现:同一个请求在两次重放里落到不同供应商,复盘时无法回答“当时为什么走了 B”。轮询在供应商数量变化时会整体错位,同样难以逐条解释。
用 requestId 做确定性哈希,再按权重换算分数,可以同时得到两个性质:同一个请求永远得到同一个首选顺序;权重大的供应商整体上更常排在第一。故障注入、回放和账单归因都依赖第一个性质。
权重不是配额。权重决定“正常情况下更可能选谁”,配额决定“选到之后能不能发”。把两者混成一个数字,会出现“权重高的供应商被过度使用,直到 429”的结果。
实现
下面的模块只做四件事:逐家准入、429 冷却、确定性分流、带全局预算的重试模拟。它不访问网络,供应商响应通过纯函数注入,因此同一份样本在任何机器上都会得到同一组数字。
// file: src/perf/stability.ts
// tier: L1
// verify: node --test test/perf/stability.test.ts
import { AiError } from "../core/errors.ts";
/** 一个供应商在当前窗口内的硬容量,rpm/tpm 都是每分钟口径。 */
export interface ProviderCapacity {
providerId: string; rpm: number; tpm: number; maxInflight: number;
/** 健康时的分流权重;权重不是配额,只是概率。 */
weight: number; healthy: boolean;
}
/** 单请求上限与全局重试预算必须同时存在。 */
export interface RetryPolicy {
maxAttempts: number; maxRetryRatio: number; crossProvider: boolean;
baseCooldownMs: number; maxCooldownMs: number;
}
export const DEFAULT_RETRY: RetryPolicy = {
maxAttempts: 3, maxRetryRatio: 0.1, crossProvider: true,
baseCooldownMs: 1_000, maxCooldownMs: 60_000,
};
export interface FleetUsage { requests: number; tokens: number; inflight: number; cooldownUntilMs: number; }
export interface Fleet { capacities: Map<string, ProviderCapacity>; usage: Map<string, FleetUsage>; }
export type AdmissionReason = "cooldown" | "rpm" | "tpm" | "inflight" | "unhealthy";
export type Admission = { ok: true } | { ok: false; reason: AdmissionReason; retryAfterMs: number };
function fail(message: string): never {
throw new AiError("invalid-request", `stability: ${message}`);
}
function integer(value: number, field: string, min = 0): number {
if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
return value;
}
function finite(value: number, field: string, min = 0): number {
if (!Number.isFinite(value) || value < min) fail(`${field} must be a finite number >= ${min}`);
return value;
}
export function createFleet(providers: readonly ProviderCapacity[]): Fleet {
if (providers.length === 0) fail("providers must not be empty");
const capacities = new Map<string, ProviderCapacity>();
const usage = new Map<string, FleetUsage>();
for (const provider of providers) {
if (provider.providerId.trim() === "") fail("providerId must not be empty");
if (capacities.has(provider.providerId)) fail(`duplicate provider ${provider.providerId}`);
integer(provider.rpm, `${provider.providerId}.rpm`, 1);
integer(provider.tpm, `${provider.providerId}.tpm`, 1);
integer(provider.maxInflight, `${provider.providerId}.maxInflight`, 1);
finite(provider.weight, `${provider.providerId}.weight`);
if (provider.weight <= 0) fail(`${provider.providerId}.weight must be > 0`);
capacities.set(provider.providerId, { ...provider });
usage.set(provider.providerId, { requests: 0, tokens: 0, inflight: 0, cooldownUntilMs: 0 });
}
return { capacities, usage };
}
function usageOf(fleet: Fleet, providerId: string): FleetUsage {
const usage = fleet.usage.get(providerId);
if (!usage) fail(`unknown provider ${providerId}`);
return usage;
}
/** 检查不修改状态;调用方可以先判断,再决定是否消耗一次重试预算。 */
export function checkAdmission(fleet: Fleet, providerId: string, tokens: number, nowMs: number): Admission {
const capacity = fleet.capacities.get(providerId);
const usage = usageOf(fleet, providerId);
if (!capacity.healthy) return { ok: false, reason: "unhealthy", retryAfterMs: 0 };
if (nowMs < usage.cooldownUntilMs) {
return { ok: false, reason: "cooldown", retryAfterMs: usage.cooldownUntilMs - nowMs };
}
if (usage.inflight >= capacity.maxInflight) return { ok: false, reason: "inflight", retryAfterMs: 0 };
if (usage.requests + 1 > capacity.rpm) return { ok: false, reason: "rpm", retryAfterMs: 60_000 };
if (usage.tokens + tokens > capacity.tpm) return { ok: false, reason: "tpm", retryAfterMs: 60_000 };
return { ok: true };
}
export function commitAdmission(fleet: Fleet, providerId: string, tokens: number): void {
const usage = usageOf(fleet, providerId);
usage.requests += 1; usage.tokens += tokens; usage.inflight += 1;
}
export function release(fleet: Fleet, providerId: string): void {
const usage = usageOf(fleet, providerId);
if (usage.inflight > 0) usage.inflight -= 1;
}
/** 429 之后让供应商休息;Retry-After 优先,缺失时用基准值,二者都被上限截断。 */
export function markRateLimited(
fleet: Fleet, providerId: string, nowMs: number,
retryAfterMs: number | undefined, policy: RetryPolicy = DEFAULT_RETRY,
): number {
const delay = Math.max(0, Math.min(retryAfterMs ?? policy.baseCooldownMs, policy.maxCooldownMs));
const usage = usageOf(fleet, providerId);
usage.cooldownUntilMs = Math.max(usage.cooldownUntilMs, nowMs + delay);
return delay;
}
/** 解析 Retry-After:秒数与 HTTP 日期都接受,非法值回退到基准冷却。 */
export function parseRetryAfter(value: string | null, nowMs: number, policy: RetryPolicy = DEFAULT_RETRY): number {
if (value === null || value.trim() === "") return policy.baseCooldownMs;
const seconds = Number(value.trim());
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, policy.maxCooldownMs);
const at = Date.parse(value);
if (Number.isFinite(at)) return Math.max(0, Math.min(at - nowMs, policy.maxCooldownMs));
return policy.baseCooldownMs;
}
function fnv1a(text: string): number {
let hash = 0x811c9dc5;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash;
}
/** 确定性哈希分流:同一个 requestId 重放多少次,首选顺序都不变。 */
export function orderProviders(fleet: Fleet, requestId: string): string[] {
return [...fleet.capacities.values()]
.filter((provider) => provider.healthy)
.map((provider) => ({
providerId: provider.providerId,
score: (fnv1a(`${requestId}:${provider.providerId}`) % 10_000) / provider.weight,
}))
.sort((left, right) => left.score - right.score || left.providerId.localeCompare(right.providerId))
.map((item) => item.providerId);
}
export interface RetryLedger { originalRequests: number; retryLimit: number; retryUsed: number; }
/** 全局重试预算按原始请求数计算,而不是按失败数计算。 */
export function createRetryLedger(originalRequests: number, policy: RetryPolicy = DEFAULT_RETRY): RetryLedger {
integer(originalRequests, "originalRequests");
finite(policy.maxRetryRatio, "maxRetryRatio");
if (policy.maxRetryRatio < 0 || policy.maxRetryRatio > 1) fail("maxRetryRatio must be in [0, 1]");
integer(policy.maxAttempts, "maxAttempts", 1);
return { originalRequests, retryLimit: Math.floor(originalRequests * policy.maxRetryRatio), retryUsed: 0 };
}
export function takeRetry(ledger: RetryLedger): boolean {
if (ledger.retryUsed >= ledger.retryLimit) return false;
ledger.retryUsed += 1;
return true;
}
/** 配额池的剩余量:把每个供应商这一分钟还能接的请求数相加。 */
export function poolHeadroom(fleet: Fleet, nowMs: number): number {
let total = 0;
for (const capacity of fleet.capacities.values()) {
const usage = usageOf(fleet, capacity.providerId);
if (capacity.healthy && nowMs >= usage.cooldownUntilMs) {
total += Math.max(0, capacity.rpm - usage.requests);
}
}
return total;
}
export interface StormRequest { requestId: string; tokens: number; }
export type StormReplyKind = "ok" | "rate-limit" | "unavailable";
export interface StormReplyInput { providerId: string; request: StormRequest; index: number; attempt: number; }
export interface StormProviderStats {
attempts: number; ok: number; rateLimited: number; unavailable: number; admissionSkips: number;
}
export interface StormReport {
original: number; attempts: number; served: number; shed: number;
rateLimited: number; unavailable: number; admissionSkips: number;
retryAttempts: number; retryLimit: number; retryBudgetExhausted: number;
amplification: number; perProvider: Record<string, StormProviderStats>;
}
export interface StormInput {
fleet: Fleet; requests: readonly StormRequest[]; policy: RetryPolicy;
/** 必须是确定性纯函数:同一个输入返回同一个结果。 */
reply(input: StormReplyInput): StormReplyKind;
nowMs?: number; cooldownMs?: number;
}
/**
* 跑一批请求,同时观察单请求上限与全局重试预算。
* 准入失败不算一次尝试,也不消耗重试预算;只有真正发出去的请求才计数。
*/
export function simulateStorm(input: StormInput): StormReport {
const nowMs = input.nowMs ?? 0;
const cooldownMs = input.cooldownMs ?? 0;
const ledger = createRetryLedger(input.requests.length, input.policy);
const perProvider: Record<string, StormProviderStats> = {};
for (const providerId of input.fleet.capacities.keys()) {
perProvider[providerId] = { attempts: 0, ok: 0, rateLimited: 0, unavailable: 0, admissionSkips: 0 };
}
let attempts = 0; let served = 0; let shed = 0; let rateLimited = 0;
let unavailable = 0; let admissionSkips = 0; let retryBudgetExhausted = 0;
input.requests.forEach((request, index) => {
let used = 0; let done = false;
for (const providerId of orderProviders(input.fleet, request.requestId)) {
if (used >= input.policy.maxAttempts) break;
if (used > 0 && !input.policy.crossProvider) break;
if (!checkAdmission(input.fleet, providerId, request.tokens, nowMs).ok) {
admissionSkips += 1; perProvider[providerId].admissionSkips += 1; continue;
}
if (used > 0 && !takeRetry(ledger)) { retryBudgetExhausted += 1; break; }
commitAdmission(input.fleet, providerId, request.tokens);
used += 1; attempts += 1; perProvider[providerId].attempts += 1;
const reply = input.reply({ providerId, request, index, attempt: used });
if (reply === "ok") {
served += 1; perProvider[providerId].ok += 1; release(input.fleet, providerId); done = true; break;
}
if (reply === "rate-limit") {
rateLimited += 1; perProvider[providerId].rateLimited += 1;
markRateLimited(input.fleet, providerId, nowMs, cooldownMs, input.policy);
} else {
unavailable += 1; perProvider[providerId].unavailable += 1;
}
release(input.fleet, providerId);
}
if (!done) shed += 1;
});
return {
original: input.requests.length, attempts, served, shed, rateLimited, unavailable, admissionSkips,
retryAttempts: ledger.retryUsed, retryLimit: ledger.retryLimit, retryBudgetExhausted,
amplification: input.requests.length === 0 ? 0 : attempts / input.requests.length, perProvider,
};
}
checkAdmission 与 commitAdmission 分开,是为了让调用方在真正发请求前决定是否消耗重试预算;准入失败不算一次尝试,也不应该占用预算。simulateStorm 里每一次重试都要先过 takeRetry,这是全局预算唯一的扣减点。
示例样本上的运行结果
样本是 600 条请求,每条 1000 token,三家供应商权重 6:3:1。场景固定:A 对 60% 的下标返回 429,B 对 30% 返回 429,C 永远成功。为了单独观察重试放大,场景把冷却设为 0;冷却本身由另一条测试覆盖。
| 策略 | 尝试 | 成功 | 放弃 | 重试 | 放大倍数 |
|---|---|---|---|---|---|
| 不重试 | 600 | 325 | 275 | 0 | 1.000 |
| 宽松重试(预算 100%) | 1023 | 600 | 0 | 423 | 1.705 |
| 有界重试(预算 10%) | 660 | 361 | 239 | 60 | 1.100 |
这组数字说明三件事。第一,宽松重试确实把 600 条全部救回来了,但代价是 1023 次尝试;多出来的 423 次全部打在备用供应商身上。第二,10% 预算只多花 60 次尝试,救回 36 条(361 对 325),每次重试的成功率约 60%,不是“重试一定划算”。第三,放大倍数从 1.705 压到 1.100,靠的不是减少单请求上限,而是全局预算。
分供应商看,宽松策略把 C 的尝试从 56 推到 233,是原来的 4.16 倍;B 从 142 推到 336,是 2.37 倍。备胎不是被“用了一下”,而是被当成了第二主供应商。这正是跨供应商重试最容易被忽略的成本:它消耗的是另一家的配额,而那家可能正在服务自己的正常流量。
接到卷一的能力分级上
卷一 10.1 把能力分成可缺、可弱、必须阻塞。限流策略必须跟着分级走,不能所有能力共用一套重试。
| 分级 | 429 时的动作 | 理由 |
|---|---|---|
| 可缺 optional | 直接隐藏入口,不重试 | 用户自己也能完成主任务,重试只是在浪费配额 |
| 可弱 degradable | 允许一次跨供应商重试,失败后切规则或模板 | 质量可以下降,但不能没有结果 |
| 必须阻塞 blocking | 不跨供应商重试,直接阻塞并告警 | 备用通道的质量未经该场景验证,放行比停住更危险 |
还有一条边界:429 不是质量信号。它只说明“现在太快”,不说明“这家模型不适合这个任务”。因为一次 429 就把请求永久切到更差的模型,会把分钟级的容量问题变成长期的质量问题。
小结
供应商限流的核心不是“再找一家”,而是把放大倍数关进预算。429 要按减速信号处理:读 Retry-After、进入冷却、冷却期内不准入。配额池要逐家检查请求数、token 数、在途数与健康状态,剩余量相加只用于判断总量,不替代逐家准入。分流顺序用确定性哈希,保证同一请求可复现。重试同时受单请求上限与全局预算约束,预算按原始请求数计算。
示例样本的结论是:宽松重试用 1.705 倍尝试换回全部 600 条,有界重试用 1.100 倍尝试换回 361 条,不重试保留 325 条。没有一种策略永远正确,但每一种的代价都可以提前算出来。
下一节把同样的预算思路用到降级链上:降级不能无限重试,也不能越降越贵。
阅读导航:上一节:11.3 灰度发布与自动回滚 · 下一节:12.2 降级链的成本约束 。
常见错误与边界
- 把 429 和 500 放进同一个重试分支。前者要继续减速,后者才适合立即换一次。
- 忽略
Retry-After的秒数与日期两种格式。解析失败时静默使用零,会立刻再次撞上限流。 - 不设冷却上限。对方给一个异常大的值,供应商会被冻结远超必要时间。
- 冷却按全局而不是按供应商。一家限流导致所有供应商暂停,故障范围被放大。
- 全局重试预算按失败数计算。故障越严重,允许的重试越多,正好在最不该加流量时加流量。
- 权重当配额。权重只决定概率,不决定对方允许你发多少。
- 用随机分流。同一个请求重放落到不同供应商,复盘无法逐条解释。
- 跨供应商重试不看备用供应商的剩余配额。备胎被打挂,正常流量一起受损。
- 所有能力共用一套重试策略。可缺能力在 429 时应该隐藏,而不是替用户重试。
- 把 429 当成质量信号。容量问题被误判为模型不适配,分钟级故障变成长期质量下降。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。