你在 server 端调用 Stripe、Shopify、Twilio、SendGrid 之类的第三方 API,开发时风平浪静,流量一上去就开始报:
HTTP 429 Too Many Requests
{
"error": {
"type": "rate_limit_error",
"message": "Too many requests"
}
}
最快的修法:给每个外发请求包一层 retry helper,读 provider 返回的等待头,按指数退避加随机抖动 sleep,并封顶在 60s 左右。光这一步就能压下大部分 429 风暴。然后给幂等 GET 加缓存、对并发的相同请求做去重,让同一个请求不会重复打到上游。问题几乎从来不是绝对意义上的”调多了”,而是你的客户端层立刻 retry、不缓存幂等读、不去重并发,于是一个小尖峰被放大成洪流。
理解关键:第三方 API 的 rate limit 通常按 (account, endpoint, time window) 的某种组合计算,而且很多 provider 还单独有一个 concurrency(并发) 上限,限制同时在途的请求数。修法不是全局”调慢一点”,而是改请求模式,从源头上不再发那些可以避免的、突发的调用。
你撞的是哪种 429?(先读响应头)
改代码之前,先看实际的响应头——它告诉你撞的是哪类限制、要等多久。截至 2026 年 6 月:
| Provider | 限流时的状态码 | 告诉你等多久的 header | 文档里的限额 |
|---|---|---|---|
| Stripe | 429 Too Many Requests | Stripe-Rate-Limited-Reason(取值:global-rate、endpoint-rate、global-concurrency、endpoint-concurrency、resource-specific) | live 模式 100 req/s,sandbox 25 req/s |
| SendGrid(Twilio) | 429 Too Many Requests | X-RateLimit-Limit、X-RateLimit-Remaining、X-RateLimit-Reset(重置时间戳) | 多数 v3 endpoint 为 600 req/min;mail send 高得多 |
| Twilio REST | 429,错误码 20429 | Twilio-Concurrent-Requests(在途请求数) | 按并发限制,不是固定 RPS |
| 通用 / RFC | 429 Too Many Requests | Retry-After(秒数,如 Retry-After: 30,或一个 HTTP-date) | 429 见 RFC 6585;Retry-After 见 RFC 9110 §10.2.3 |
有两点要注意。第一,Stripe 不发标准的 X-RateLimit-* 或 Retry-After——它发的是 Stripe-Rate-Limited-Reason,所以一个只会”读 Retry-After”的通用 helper 从 Stripe 拿不到任何值,必须回退到退避。第二,Twilio 的 429 通常是并发上限,而不是每秒速率——即使你总量不大,降低并行请求数也能修好。
常见原因
按命中率从高到低:
1. 429 没退避,立刻 retry
代码 try { fetch() } catch { setTimeout(fetch, 100) }——100ms 后还是 429,无限重试反而把窗口锁死。
如何判断:日志里同一条代码路径短时间内 429 连续出现 10+ 次。
2. 不读等待头
provider 在 429 里告诉你要等多久(Retry-After、X-RateLimit-Reset,Stripe 则是直接让你”退避”)。固定 sleep 时间忽略了这个建议,几乎必然继续撞墙。
如何判断:代码里有固定的 await sleep(1000),完全没读 header。
3. 多 worker 重复调同样 endpoint
你的服务有 10 个 worker,每个都在 GET /products/123 拉同一个商品。每个 worker 都算自己的请求量”还好”,加起来超限。
如何判断:日志显示同一个 URL 在同一秒内被多次调用。
4. 不缓存幂等 GET
GET /products 每次调用都打 upstream。即使商品列表 1 小时不变,你也每个 user request 调一次新的。
如何判断:log 里慢变化数据出现频繁重复的 GET。
5. 突发流量(cron、批处理)打爆限额
Promise.all([100 个并发 fetch]) 瞬时把”每分钟 60 次”用光,或触发并发上限,立刻 429。
如何判断:代码里对大数组有大 fan-out(Promise.all、parallel map)。
6. 共享 API key 多服务用
后端、CI、cron job 都用同一个 API key,每个都觉得 quota 够用,加起来超额。
如何判断:provider 后台显示这个 key 上有多个来源 IP 或多个服务。
最短修复路径
Step 1:指数退避 + 尊重等待头
async function fetchWithRetry(url: string, opts: RequestInit = {}, maxRetries = 5) {
const baseDelay = 1000;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const res = await fetch(url, opts);
if (res.status !== 429) return res;
// RFC 9110 Retry-After(秒)。Stripe 不发,缺失就当 0。
const retryAfter = parseInt(res.headers.get('retry-after') || '0', 10);
const backoff = baseDelay * 2 ** attempt + Math.random() * 250; // 指数 + 抖动
const wait = Math.min(Math.max(retryAfter * 1000, backoff), 60_000);
console.warn(`429 on ${url}, sleeping ${wait}ms (attempt ${attempt + 1})`);
await new Promise((r) => setTimeout(r, wait));
}
throw new Error(`Max retries exceeded for ${url}`);
}
先读 Retry-After;缺失(Stripe)就回退到带抖动的指数退避,封顶 60s。那个抖动(Math.random() * 250)正是用来避免 thundering herd——否则所有正在重试的客户端会在同一瞬间醒来。Stripe 也明确建议在退避里加随机性。
注意:官方的 Stripe 和 OpenAI Node SDK 内部已经会对 429 做带退避的自动重试,所以如果你是通过它们的 SDK 调用,这一步基本白送——这个 helper 是给那些不自带重试的、直接 fetch 的 API 用的。
Step 2:幂等 GET 加缓存
// 短窗口内存缓存。lru-cache v7+ 导出的是 LRUCache(命名导出,不是 default)。
import { LRUCache } from 'lru-cache';
const cache = new LRUCache<string, unknown>({ max: 1000, ttl: 60_000 }); // 60s
async function getProduct(id: string) {
const key = `product:${id}`;
const hit = cache.get(key);
if (hit) return hit;
const res = await fetchWithRetry(`/api/products/${id}`);
const data = await res.json();
cache.set(key, data);
return data;
}
(如果你还停留在 lru-cache v6,用的是 default import;v7+ 改成了命名导出 LRUCache,所以 import LRU from 'lru-cache' 现在会抛类型错误。)
长窗口或多实例部署用 Redis(Upstash 免费档够用),让所有 worker 共用一份缓存:
import { Redis } from '@upstash/redis';
const redis = Redis.fromEnv();
async function getProduct(id: string) {
const cached = await redis.get(`product:${id}`);
if (cached) return cached;
const res = await fetchWithRetry(`/api/products/${id}`);
const data = await res.json();
await redis.set(`product:${id}`, JSON.stringify(data), { ex: 3600 }); // 1h
return data;
}
Step 3:请求去重(coalescing,合并并发的相同请求)
const inFlight = new Map<string, Promise<unknown>>();
async function getProductDedup(id: string) {
const key = `product:${id}`;
const existing = inFlight.get(key);
if (existing) return existing;
const promise = fetchWithRetry(`/api/products/${id}`)
.then((r) => r.json())
.finally(() => inFlight.delete(key));
inFlight.set(key, promise);
return promise;
}
10 个 worker 在同一瞬间都 call getProductDedup(123),最终只发 1 个上游请求。这是对付 Twilio 那种并发上限的最大收益点。
Step 4:限流 fan-out
import pLimit from 'p-limit';
const limit = pLimit(5); // 同时最多 5 个在途
const results = await Promise.all(
items.map((item) => limit(() => fetchItem(item))),
);
并发数从文档推:文档 RPM / 60 大致给出 RPS 上限,60 RPM 的 endpoint 安全在途数通常是 1-3。对 Twilio 这是硬要求——把限流器对齐到账号的并发预算,而不是它的消息吞吐量。
Step 5:批量 endpoint
很多 API 有 batch / bulk 版本,一个请求干完原来 N 个请求的活:
低效:N 次 GET /users/{id}
高效:1 次 POST /users:batchGet { ids: [...] }
读文档找 batch endpoint(Stripe 可以在一个调用里 expand 关联对象,SendGrid 的 mail send 一个请求可带多个收件人),把请求数降下来。
Step 6:按服务拆 API key
后端 service A → KEY_A
后端 service B → KEY_B
CI / cron → KEY_C
每个 key 独立 quota,失控的 cron job 就不会饿死线上流量。诊断”哪个服务在刷”的时候,后台也更容易看清。
Step 7:升级 plan / 申请提高 quota
退避、缓存、去重、批量、拆 key 都做完了还在踩限,就向 provider 申请提高限额。Stripe 需要联系 support 申请持续提升;SendGrid 和 Twilio 会随着你的规模或升级 plan 放宽并发/吞吐限制。
如何确认修好了
- 盯 429 比例。每次 429 都把 URL 和相关 header(
Retry-After/Stripe-Rate-Limited-Reason/Twilio-Concurrent-Requests)记进日志。修完后,同样流量下它应该降到接近 0。 - 重跑那次突发。重放当初触发问题的 cron job 或压测。有了 p-limit 和去重,上游请求数应该远低于处理的条目数。
- 看缓存命中率。对加了缓存的 GET,确认大多数请求现在从缓存返回(打一行
cache HIT/MISS日志),而不是打上游。 - 对回归报警。设一个告警:429 比例超过约 0.5% 就触发——说明你又在踩限了,应该在用户察觉前就优化。
常见问题
429 可以安全 retry 吗?
幂等读(GET)可以,明确说可以的 provider 也可以——Twilio 就明确表示被 429 的请求没有被处理,退避后重试是安全的。但写操作(POST 一笔扣款、发一条短信)要用 provider 的 idempotency key(Stripe 的 Idempotency-Key header 等),这样重试不会造成重复。
Stripe 返回 429 却没有 Retry-After,那我该等什么?
Stripe 不发 Retry-After,它发 Stripe-Rate-Limited-Reason 解释为什么被限(速率还是并发、全局还是某个 endpoint)。这种情况回退到带抖动的指数退避;如果 reason 是 *-concurrency,要降低并行度,而不是只把速率调慢。
我把重试次数调高,反而更糟,为什么? 重试没有抖动会造成 thundering herd:所有客户端按同一节奏重试,在同一时刻再次一起把限额冲爆。给退避加随机性,并给总尝试次数封顶(5 次足够);再多就该把任务排队,而不是一直砸。
我总量明明远低于限额,却还在 429。
你大概率撞的是 concurrency 上限而不是速率上限(Twilio 常见)。10 个并行请求即使总量很低也能触发。加上请求去重(Step 3)和 p-limit 并发上限(Step 4)。
用内存缓存还是 Redis?
内存缓存(lru-cache)适合单进程、或者每实例数据略微过期也能接受的场景。一旦你跑多 worker、或想要共享的更长 TTL,就换 Redis,让每个实例读同一份缓存,不再按实例数成倍放大上游调用。
预防建议
- 接入任何新 API 第一件事查 rate limit 文档,估算 peak RPS,留 30% buffer。
- 所有外发请求包一层 retry helper,禁止裸
fetch调第三方。 - 幂等 GET 默认缓存,TTL 按数据新鲜度(价格 60s、目录 1h、配置 1day)。
- 多 worker 部署必须去重 + 用共享缓存(Redis),不要 in-memory。
- Fan-out 用
p-limit控并发,永远不用裸Promise.all跑 100+。 - 每个 service 用独立 API key,便于隔离故障、分摊 quota。
- 把 429 比例当 SLO 维护:每个上游分配多少 RPM,超了就报警。