你在 server 端调用 Stripe、Shopify、Twilio、SendGrid 之类的第三方 API,开发时风平浪静,流量一上去就开始报:
HTTP 429 Too Many Requests
{
"error": "rate_limit_exceeded",
"retry_after": 5
}
而且你的代码立刻 retry,结果限流更紧;或者三个 worker 各自调同一个 endpoint,加起来超额。问题不是”调多了”,而是你的客户端层没正确处理 429,没缓存幂等 GET,没去重并发。
理解关键:第三方 API 的 rate limit 通常按 (account, endpoint, time window) 三元组计算。修法不是”调慢一点”,是把请求模式改对。
常见原因
按命中率从高到低:
1. 429 没退避,立刻 retry
代码 try { fetch() } catch { setTimeout(fetch, 100) }——100ms 后还是 429,无限重试反而把窗口锁死。
如何判断:日志里短时间内 429 连续出现 10+ 次。
2. 不读 Retry-After header
provider 在 429 响应里告诉你”等 X 秒再来”,你的代码用固定 sleep 时间忽略这个建议,几乎必然继续撞。
如何判断:代码里有 await sleep(1000) 类硬编码。
3. 多 worker 重复调同样 endpoint
你的服务有 10 个 worker,每个都在 GET /products/123 拉同一个商品。每个 worker 都算自己的请求量”还好”,加起来超限。
如何判断:日志显示同一个 URL 短时间内被多次调用。
4. 不缓存幂等 GET
GET /products 每次调用都打 upstream。即使商品列表 1 小时不变,你也每个 user request 调一次。
如何判断:log 里频繁出现重复的 GET。
5. 突发流量(cron、批处理)打爆 RPM
Promise.all([100 个并发 fetch]) 瞬时把 API 的”每分钟 60 次”用光,立刻 429。
如何判断:代码里有大 fan-out(Promise.all、parallel map)。
6. 共享 API key 多服务用
后端、CI、cron job 都用同一个 API key,每个都觉得 quota 够用,加起来超额。
如何判断:API console 看 key 来源 IP 是否多个。
最短修复路径
Step 1:实现指数退避 + 尊重 Retry-After
async function fetchWithRetry(url, opts = {}, maxRetries = 5) {
let baseDelay = 1000;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const res = await fetch(url, opts);
if (res.status !== 429) return res;
const retryAfter = parseInt(res.headers.get('retry-after') || '0', 10);
const backoff = baseDelay * Math.pow(2, attempt) + Math.random() * 250;
const sleep = Math.min(Math.max(retryAfter * 1000, backoff), 60_000);
console.log(`429, sleeping ${sleep}ms (attempt ${attempt + 1})`);
await new Promise(r => setTimeout(r, sleep));
}
throw new Error('Max retries exceeded');
}
关键:先读 Retry-After,没有再用指数退避,封顶 60s。
Step 2:幂等 GET 加缓存
// 短窗口内存 LRU
import LRU from 'lru-cache';
const cache = new LRU({ max: 1000, ttl: 60_000 }); // 60s
async function getProduct(id) {
const key = `product:${id}`;
if (cache.has(key)) return cache.get(key);
const res = await fetchWithRetry(`/api/products/${id}`);
const data = await res.json();
cache.set(key, data);
return data;
}
或长窗口用 Redis(Upstash 免费档够用):
import { Redis } from '@upstash/redis';
const redis = Redis.fromEnv();
async function getProduct(id) {
const cached = await redis.get(`product:${id}`);
if (cached) return cached;
const data = await (await fetchWithRetry(...)).json();
await redis.setex(`product:${id}`, 3600, JSON.stringify(data)); // 1h
return data;
}
Step 3:请求去重(coalescing)
const inFlight = new Map<string, Promise<any>>();
async function getProductDedup(id) {
const key = `product:${id}`;
if (inFlight.has(key)) return inFlight.get(key);
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 个上游请求。
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)))
);
按 API 文档的 RPM / 60 算并发上限:60 RPM = 1 RPS = 通常 1-3 并发安全。
Step 5:批量 endpoint
很多 API 有 batch 版本:
低效:N 次 GET /users/{id}
高效:1 次 POST /users:batchGet {ids: [...]}
读文档找 batch / bulk endpoint,省 RPS。
Step 6:拆 API key
后端 service A → KEY_A
后端 service B → KEY_B
CI / cron → KEY_C
每个 key 独立 quota,故障隔离。
Step 7:升级 plan / 申请 quota
如果你已经把 cache + batch + dedup 都做了还是不够,去 API provider 后台申请提高 quota 或升级 plan。Stripe、SendGrid 都能改。
预防建议
- 接入任何新 API 第一件事查 rate limit 文档,估算 peak RPS 留 30% buffer
- 所有 fetch 包一层 retry wrapper,禁止裸 fetch
- 幂等 GET 默认缓存,TTL 按数据新鲜度(价格 60s、目录 1h、配置 1day)
- 多 worker 场景必须 dedup + 用共享缓存(Redis),不要 in-memory
- Fan-out 用 p-limit 控并发,永远不用裸 Promise.all 跑 100+
- 每个 service 用独立 API key,便于隔离故障和分摊 quota
- 监控 429 比例:> 0.5% 就该优化(已经在踩限了)
- 把”API rate limit budget” 当 SLO 维护:每个上游分配多少 RPM,超了报警