第三方 API 触发 rate limit

Stripe、Shopify、Twilio 等第三方 API 在流量上来时频繁 429——本文按命中率拆原因,给出指数退避、本地缓存、请求合并和并发限流四套修复模式,每条都附代码片段。

你在 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,超了报警

相关阅读

标签: #后端 #排查 #排查