Node.js 调 X API:类型化错误与并发控制

18 min readSocialAPI 工程团队

Node.js 调 X API:类型化错误与并发控制

X API 的示例大多是 Python。这没问题——直到你在一个 Node 服务里,要决定 Promise.all 内部怎么处理 429,而有意思的问题恰好都在那儿。

Node 带来了 Python 没有的两样东西:一个值得用在 API 响应上的类型系统,以及一个让你极容易误发两百个并发请求的异步模型。这篇讲这两件事,外加一个在 JS 里比在任何别的地方都更好读的翻页写法。

有个免费 demo 端点,可以在写客户端之前先拿响应结构对一下你的类型。


准备

npm i @socialapitech/sdk
import { SocialAPI } from "@socialapitech/sdk";

const api = new SocialAPI("sk_your_key");   // 或者设 SOCIALAPI_KEY

const user = await api.user_info("elonmusk");
console.log(user.followers_count);

Node 18+ 才有原生 fetch,这也意味着同一份代码在 Deno 和 Bun 里不用改就能跑。不想加依赖的话,这个 API 就是普通 HTTP 加一个请求头——自己包一层 fetch 二十行就够,这篇其余内容照样适用。


按错误类型分支,不是按状态码

这才是真正要紧的部分,也是大多数 Node 客户端写错的地方。

429 和 402 都是「你的请求失败了」,而把它们同等对待的代码会朝两个方向之一出错:要么永远重试一个 402,要么放弃一个本来会成功的 429。

import { SocialAPI, RateLimited, InsufficientCredits, SocialAPIError }
  from "@socialapitech/sdk";

try {
  const tweets = await api.user_last_tweets("naval", { limit: 50 });
  process(tweets);
} catch (err) {
  if (err instanceof RateLimited) {
    // 429 —— 不计费。等一下就好了。
    await sleep(5_000);
  } else if (err instanceof InsufficientCredits) {
    // 402 —— 确定性失败。重试只是烧时间, 什么都不会变。
    await alertOps("out of credits");
    throw err;
  } else if (err instanceof SocialAPIError) {
    console.error(`API error ${err.status}:`, err.message);
    throw err;
  } else {
    throw err;   // 不是我们的 —— 网络、解析、或者你的 bug
  }
}

★值得内化的区别:429 是暂时的而且不花钱,402 在你采取行动之前是永久的。★ 限流不计费,所以重试在两个意义上都是免费的。额度耗尽会在每一次尝试上都返回 402,直到有人充值为止——重试它是一个永不退出的循环。

SDK 默认已经对 429 和 5xx 重试两次,并且永不重试 402,所以上面这个 handler 处理的是重试之后剩下的情况。想要别的行为:

const api = new SocialAPI({
  apiKey: process.env.SOCIALAPI_KEY,
  maxRetries: 4,      // 只作用于 429/5xx
  timeoutMs: 60_000,
});

把这件事写明值得一行配置,因为适合批处理任务的默认值很少适合请求处理器。自己撞的是三种限流里的哪一种也值得搞清楚——「rate limit exceeded」到底指什么把它们分开讲了。


并发:Promise.all 诱导你犯的错

// 别这么写。这会一次发出 500 个请求。
const users = await Promise.all(handles.map(h => api.user_info(h)));

十个账号没问题。五百个你就开了五百个 socket,然后你会看到超时和限流——看起来像 API 的问题,其实是自己造成的。

一个有上限的并发池,零依赖:

async function mapLimit<T, R>(
  items: T[],
  limit: number,
  fn: (item: T) => Promise<R>
): Promise<(R | Error)[]> {
  const results: (R | Error)[] = new Array(items.length);
  let next = 0;

  const worker = async () => {
    while (true) {
      const i = next++;                 // 单线程: 这里不会有竞态
      if (i >= items.length) return;
      try {
        results[i] = await fn(items[i]);
      } catch (e) {
        results[i] = e as Error;        // 保住位置, 不要中断整批
      }
    }
  };

  await Promise.all(
    Array.from({ length: Math.min(limit, items.length) }, worker)
  );
  return results;
}

const results = await mapLimit(handles, 5, h => api.user_info(h));

这里有两处承载了全部重量。

const i = next++ 之所以安全,是因为 JavaScript 是单线程的——这个自增不会交错执行。同样的写法在 Go 或 Java 里需要一把锁。 这是少数几个 Node 的模型让正确代码更短的地方。

★把错误存在它对应的下标上、而不是抛出去★,意味着一个坏账号不会把 499 个好结果一起丢掉。Promise.all 在第一个失败时就 reject 并放弃其余全部,而这对批处理几乎从来不是你想要的。Promise.allSettled 解决了中断问题,但仍然是一次性全部发出。

如果端点有批量形式,用它而不是用并发。 一个请求取一百份资料,在任何并发级别上都优于一百个请求——SDK 为此暴露了 batch_user_info 和 tweets_batch,批量化请求讲了它什么时候划算。


把翻页写成异步迭代器

先搞清楚哪些端点会翻页。★时间线不翻页★:user_last_tweets 一次返回最多 limit 条(上限 200)、没有游标。★列表才翻页★:粉丝、关注、转推者、列表成员会在 meta 里给 next_cursor。

对会翻页的那些,这是 JS 真正比其他语言读起来更好的地方。 不要写一个累加进数组的循环,而是暴露一个让调用方按需消费的流。它要读原始响应里的 meta,所以直接基于 fetch 来写:

const BASE = "https://api.socialapi.tech/v1";
const HEADERS = { "X-API-Key": process.env.SOCIALAPI_KEY! };

async function* followers(
  username: string,
  pageSize = 200
): AsyncGenerator<User, void, undefined> {
  let cursor: string | undefined;

  while (true) {
    const qs = new URLSearchParams({ username, limit: String(pageSize) });
    if (cursor) qs.set("cursor", cursor);

    const res = await fetch(`${BASE}/user/followers?${qs}`, { headers: HEADERS });
    if (!res.ok) throw new Error(`followers: HTTP ${res.status}`);
    const body = await res.json();

    const page: User[] = body.data ?? [];
    if (!page.length) return;
    yield* page;

    cursor = body.meta?.next_cursor;
    if (!cursor) return;
  }
}

// 调用方任何时刻内存里都不会超过一页
for await (const user of followers("naval")) {
  await handle(user);
  if (someStopCondition(user)) break;   // ← 立刻停止取数
}

★那个 break 就是全部意义所在。★ 一个返回数组的函数必须先取完全部,你才能看到其中任何一条。异步生成器在消费方停止消费的那一刻就停止发请求——所以「找到第一个满足 X 的粉丝」只花一页,而不是整个列表。

这同时意味着无论你翻多深,内存都是平的,而这在粉丝列表有几百万人时很要紧。


给响应加类型,但不要自欺

SDK 带了声明文件,所以返回值是有类型的。接下来的诱惑是把这些类型当成保证:

// 乐观写法 —— 自己看的看板没问题, 在数据管道里有风险
const bio: string = user.bio;

★字段是否存在是运行时性质,不是编译期性质。★ 类型断言不验证任何东西——如果 bio 不存在,string 就是错的,而你会在下游才发现。凡是要喂给存储或另一个服务的,在边界上做校验:

import { z } from "zod";

const TweetRow = z.object({
  id: z.string(),                        // 字符串, 不是数字 —— 19 位 ID
  text: z.string(),
  created_at: z.string(),
  like_count: z.number().int().nonnegative(),
  view_count: z.number().int().nonnegative().optional(),
});

const parsed = TweetRow.safeParse(raw);
if (!parsed.success) {
  logger.warn({ id: raw?.id, issues: parsed.error.issues }, "unexpected shape");
  return;                                // 跳过这一行, 让任务活着
}

★id 必须是 z.string()。★ 推文 ID 是 19 位 snowflake 值,超过 Number.MAX_SAFE_INTEGER(2^53 − 1),所以当数字解析会静默损坏末几位——而如果 API 哪天不加引号发过来,JSON.parse 就会这么干。★这是 JS 客户端在 X 上最常见的数据完整性 bug,而且它失败得毫无声音。★ 完整字段布局在读懂推文对象那篇。

// 为什么要紧
Number("1794060407879118301")   // → 1794060407879118300  ← 末位没了
BigInt("1794060407879118301")   // → 1794060407879118301n ← 精确

做校验还顺便给了你一个放 logger.warn 的地方——结构变化变成一行日志而不是一次崩溃,这正是无人值守的东西该有的行为。


在 serverless 函数里跑

和常驻进程有两处不同:

在 handler 外面构造客户端。 模块作用域在热调用之间会保留,每次请求构造一次等于白白丢掉连接复用。

不要开流。 WebSocket 需要一个持续活着的进程,而函数没有。需要往 serverless 目标推送,那就是 webhook 的用途——三种机制对比讲了怎么选。

// 模块作用域 —— 热调用之间复用
const api = new SocialAPI({ apiKey: process.env.SOCIALAPI_KEY });

export async function handler(event) {
  const user = await api.user_info(event.username);
  return { statusCode: 200, body: JSON.stringify(user) };
}

这个 SDK 是从我们的 OpenAPI spec 生成的,所以方法名和类型跟着 API 走、不会和它漂移。用 Google 一键注册送 $0.20 额度、不用信用卡——够你把上面全部内容拿真实响应跑一遍。


常见问题

X API 有官方的 Node 客户端吗? X 为自己的 API 提供客户端,但那套主要围绕写操作、按返回资源计费。只读数据场景下,第三方 SDK 在接口和价格上通常都更合适。

怎么用 TypeScript 调 X API? 装一个带声明文件的 SDK,或者自己包 fetch 并用 schema 库校验响应。无论哪种都要在边界校验——带类型的返回值不是运行时保证。

需要 node-fetch 吗? Node 18 及以后不需要,fetch 是全局的。这也意味着同一份代码在 Deno、Bun 和边缘运行时里不用改。

为什么要捕获错误类型而不是判断状态码? 因为正确反应因类型而异。 429 是暂时的且不计费,该重试;402 在充值之前是确定性的,重试它就是死循环。按状态码相等判断往往会把这个区别抹平。

429 会计费吗? 不会。 被限流的请求不计费,这正是为什么退避重试是正确反应而不是昂贵反应。

402 该重试吗? 绝不。 它意味着你额度用光了,而且在有人充值之前会一直这么意味着。该告警,不该重试。

重试次数的安全默认值是多少? 429 和 5xx 重试两次,除 429 之外的 4xx 不重试。 SDK 默认就是这样,对批处理任务是个合理起点。

为什么 Promise.all 在这里有危险? 两个原因:它同时发出所有请求,而且在第一个失败时 reject 并丢弃其余全部。五百个的批量你需要的是有上限的并发加逐项错误捕获。

并发该设多少? 从 5 左右开始,一边提高一边观察 429。 正确数值取决于你的套餐和端点,没有一个值得照抄的通用值。

Promise.allSettled 能解决吗? 它解决了中断,但没解决并发洪峰——所有请求仍然同时开始。那需要一个有上限的并发池。

为什么 next++ 不用锁就安全? 因为 JavaScript 在单线程上执行,读-自增-写不会交错。同样的写法在多线程语言里需要一把锁。

该用并发控制库吗? p-limit 挺好、经过充分测试。上面那十五行存在的意义是让你看清它在做什么——顺便,如果你在意依赖数量,它也省掉了一个。

Node 里怎么翻页? 只有列表类端点需要——时间线一次就返回 limit 以内的全部。粉丝和列表用异步生成器:调用方得到一个 for await 循环,内存是平的,而且 break 会立刻停止取数,不是等全部下载完之后。

为什么用异步迭代器而不是返回数组? 因为数组逼你先取完全部,调用方才能看到任何东西。 用迭代器,「找第一个匹配」只花一页而不是整个列表。

跳出 for-await 循环真的会停止请求吗? 会——生成器在 yield 处挂起,永远不会执行下一次 fetch。这就是让提前退出变便宜的那个性质。

TypeScript 里推文 ID 为什么必须是字符串? 它是 19 位、超过 Number.MAX_SAFE_INTEGER。Number("1794060407879118301") 返回的值以 ...300 结尾——错的,而且不报错。保持字符串,或者需要做算术时用 BigInt。

JSON.parse 会损坏大 ID 吗? 如果数字不带引号就会,因为它会解析成 double。行为良好的 API 为此把 ID 当字符串发,而校验「它是字符串」能在哪天不是时抓住。

SDK 有类型了还需要 Zod 吗? 自己盯着的看板不需要。 要写入存储或喂给另一个服务的需要,因为类型标注在运行时什么都不校验。

响应结构意外变化怎么处理? 校验、带上该行 ID 记日志、跳过这一行。 长跑任务不该因为一条记录奇怪就死掉。

serverless 函数里该在哪构造客户端? 模块作用域,这样热调用能复用它。放在 handler 里构造等于白丢连接复用。

能在 Lambda 里跑 WebSocket 流吗? 不太行——流需要常驻进程。serverless 目标用 webhook,那是它存在的场景。

怎么设单请求超时? 用客户端选项,或者用裸 fetch 时配 AbortController。在请求处理器里不要让它无界——上游卡住会变成你的故障。

这个能在浏览器里用吗? 技术上能,但不要——那会把你的 API key 暴露给每一个访客。经你自己的后端代理,key 留在服务端。

该用 ESM 还是 CommonJS? ESM。 这个包是纯 ESM("type": "module"),而这也是所有当前运行时偏好的形式。

怎么从 Node 查剩余额度? 有一个查余额和用量的账户端点,而且调用免费。批处理任务里值得轮询它,这样你是按自己的方式失败,而不是跑到一半挂掉。

Node 客户端在 X 上最常见的错误是什么? ★把推文 ID 当数字解析。★ 它静默损坏数据、在小样本测试里活得下来,然后在很久以后以「这些行 join 不上」的形式浮现。


这篇只要带走一件事的话:ID 保持字符串、并发要有上限、按错误类型分支。 用 Google 注册送 $0.20 额度、不绑卡,够你把上面的例子跑一遍、看那几个类型化错误真的被触发。