Node.js · 原创实验

并发限制设为 2,接口为什么仍然返回 429?

8 个请求、并发上限 2,第一组仍有 5 次 429。给同一限制器加上批次间隔后,8 次全部返回 200。用本地 HTTP 服务拆开并发数与时间配额。

记录时间: (北京时间) · Node v22.22.3 · darwin 27.0.1 / arm64

文章语言: English

并发上限已经压到 2,日志里却还有 429。继续把上限改成 1,有时仍然解决不了问题:请求只要结束得足够快,下一个请求就会立刻补上。

这次搭了一个很小的本地接口:每 500 毫秒接受 3 个请求,成功响应等 15 毫秒再返回。客户端总共发 8 个请求,用自写的限制器保持最多 2 个任务在执行。两轮运行里,单独限制并发都得到 3 次 200、5 次 429。

两轮实际结果

同一脚本两轮独立运行的结果
情况第 1 轮第 2 轮
只限制并发为 2最大在途请求 2;200:3;429:5最大在途请求 2;200:3;429:5
保留并发限制,按批次发请求最大在途请求 2;200:8;429:0最大在途请求 2;200:8;429:0

空出一个位置,就补上一个请求

这个限制器只做一件事:运行中的任务少于 2 个,就从队列里取下一个。它没有记录这一段时间已经发出多少请求。

接口在处理第三个请求之后就用完了当前窗口的配额。后续请求仍然可以进入客户端的空闲位置,但服务端收到它们时,500 毫秒窗口还没有结束。把两个上限放到同一张日志里看,429 就不矛盾了。

// 自写限制器的调度条件
if (active >= maximum || !waiting.length) return;
active++;
Promise.resolve().then(fn).then(resolve, reject).finally(() => {
  active--;
  drain();
});

第二组只加了发出请求的间隔

第二组仍然最多并发 2 个任务。区别是编号 0–2 的请求可以立即执行,3–5 至少等到起点后的 650 毫秒,6–7 至少等到 1300 毫秒。这样为实验接口的 500 毫秒窗口留出余量。

两轮里第二组的 8 个请求都返回 200。在途请求的峰值仍然是 2。成功来自改变发出请求的时间,调大并发数没有参与这次修正。

const batch = Math.floor(index / 3);
const earliestStart = batch * 650;
await delay(Math.max(0, earliestStart - (performance.now() - start)));
const response = await fetch(origin, { signal: AbortSignal.timeout(5000) });

遇到真实接口时,先记下两个数字

一组是同一时刻有多少请求尚未结束,另一组是配额窗口内一共发出了多少次请求。重试也会新增请求,不能只数成功结果。建议给每次尝试记录发出时间、完成时间、状态码和重试编号。

这份服务端代码会返回 Retry-After,但第二组使用的是事先知道的实验配额,没有实现通用重试器。接入真实服务时,要先确认配额是按账户、令牌还是其他维度计算,再处理服务端给出的等待时间。不要把本例的 650 毫秒当成所有接口的合适间隔。

下载后,先看状态码,再看耗时

完整脚本会分别启动两个新服务端,跑完一组再关闭连接;第二轮重新开始。记录里保留每次响应的状态码、在途请求峰值和总耗时。这里用状态码检查调度结果,耗时只用于观察本次运行。

如果你准备换成项目里正在用的并发库,可以保留本地接口,只替换调度器。先复现只限并发时的 429,再加入符合实际配额的等待策略。这样能看出修改是否解决了原来的触发条件。

下载脚本,在自己的机器上复跑

使用 Node.js 22 或更高版本,不需要安装依赖。脚本只使用 Node 内置模块;每次执行都会跑两轮并检查断言。记录路径必须尚不存在,避免覆盖上次结果。

node --version
node run.mjs
node run.mjs --record my-results.json

本例只请求 127.0.0.1,限制器和接口都是自写代码,没有运行 p-limit 或压测远端服务。服务端使用固定窗口;滑动窗口、令牌桶和账户级共享配额需要单独验证。调度依赖本机时钟,脚本断言不符时会停止,不保存成功记录。

这是同一操作者在本机完成的两轮记录,尚无独立读者复跑反馈。换环境后请保留自己的输出;断言失败会终止脚本。

核对脚本 SHA-256

889c29182c1aa1a0b2af9e91a705ad7b1a1e25ce1e6db3804dee473b6fcec673

摘要对应页面提供的脚本文件内容。完整运行数据在 verification.json。