修复第三方 API 的 429 限流错误

Stripe、Twilio、SendGrid 在流量上来时都会返回 429。按顺序处理:读限流响应头、加带抖动的指数退避、缓存幂等 GET、去重并发请求、批量写——每条都附可运行代码。

你在 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文档里的限额
Stripe429 Too Many RequestsStripe-Rate-Limited-Reason(取值:global-rateendpoint-rateglobal-concurrencyendpoint-concurrencyresource-specificlive 模式 100 req/s,sandbox 25 req/s
SendGrid(Twilio)429 Too Many RequestsX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset(重置时间戳)多数 v3 endpoint 为 600 req/min;mail send 高得多
Twilio REST429,错误码 20429Twilio-Concurrent-Requests(在途请求数)按并发限制,不是固定 RPS
通用 / RFC429 Too Many RequestsRetry-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-AfterX-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 也明确建议在退避里加随机性。

注意:官方的 StripeOpenAI 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 放宽并发/吞吐限制。

如何确认修好了

  1. 盯 429 比例。每次 429 都把 URL 和相关 header(Retry-After / Stripe-Rate-Limited-Reason / Twilio-Concurrent-Requests)记进日志。修完后,同样流量下它应该降到接近 0。
  2. 重跑那次突发。重放当初触发问题的 cron job 或压测。有了 p-limit 和去重,上游请求数应该远低于处理的条目数。
  3. 看缓存命中率。对加了缓存的 GET,确认大多数请求现在从缓存返回(打一行 cache HIT/MISS 日志),而不是打上游。
  4. 对回归报警。设一个告警: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,超了就报警。

相关阅读

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