症状
错误率图上的锯齿就是签名:错误攀升、恢复开始、随后一波同步重试再次打爆。被无视的契约就在响应头里:Retry-After: 30 与 X-RateLimit-Remaining: 0。接着量化放大:每请求尝试次数分布拖着重尾(10 次以上)说明没有预算;多层重试栈(网关 × 服务 × SDK)把一个逻辑请求放大成十几个上游调用,全共享同一固定退避、也就共享同一个醒来时刻——同步波由此成形。
诊断思路
先读响应头里的 Retry-After 与限流头,那是服务器声明的安全重入时刻;再统计每请求的尝试次数分布,重尾即无预算;最后对齐各层重试策略,多层独立重试相乘是放大器,重试应收敛到最贴近故障的一层。客户端日志显示毫秒级重试而响应明确说等 30 秒,是最直接的违规证据。
根因
同步重试把瞬时限流响应变成持续过载:限流触发时所有被拒请求共享同一恢复时序,同时重发再次触限,系统在过载与恢复之间循环而不收敛。
修复与验证
以服务器提示为锚的全抖动指数退避:上限取 Retry-After 与 2^attempt 的较大者、延迟取 [0, cap) 随机值;每请求设尝试预算(如 4 次);只重试幂等操作或携带 Idempotency-Key——服务器可能已处理请求,盲目重试非幂等操作会双写;重试收敛到单一层级;客户端在触限前就排队或降载。验证时在上游劣化场景下重试波从指标上消失:上游请求率保持平坦、错误单调收敛、客户端时序日志显示遵守了 Retry-After。
代码示例
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Remaining: 0
async function callWithRetry(url, opts) {
const maxAttempts = 4;
let attempt = 0;
while (true) {
const res = await fetch(url, opts);
if (res.status !== 429) return res;
if (++attempt >= maxAttempts) throw new RateLimitError(attempt);
const serverHint = Number(res.headers.get('retry-after')) || 0;
const cap = Math.max(serverHint, 2 ** attempt); // never below server hint
const delay = Math.random() * cap; // full jitter
await sleep(delay);
}
}
retry policy: max 3, jittered, idempotent methods only (or Idempotency-Key header)