如何在 Inngest 中识别验证码并避免重复识别

inngest captcha - How to Solve CAPTCHAs in Inngest Without Solving Twice

在 Inngest 里,验证码识别必须放在同一个 step.run 调用内部,而且 token 也要在这同一个调用里用掉。Inngest 会在每个 step 边界从头重新执行你的函数,所以 step 之外的任何代码每次都会再跑一遍。而已完成的 step 会被记忆化,于是第一个 step 里识别出的 token,会在第四个 step 里被不加改动地重放,那时它早就过期了。这两条规则都源自执行模型,而不是识别工具本身。

你需要什么

  • 一个带 serve 端点的 Inngest 应用,通常在 /api/inngest,以及用于本地运行的 Inngest Dev Server。
  • CapSkip 运行在一台 Windows 机器上,Node 客户端安装在提供函数服务的那个应用里。
  • sitekey 和页面 URL,通过事件负载传入,而不是写死在函数里。
  • 只要应用部署在识别工具本机之外的地方,就需要 Server 模式,而这是大多数部署的情况。这只是连接设置里的一个选项。
# npm install capskip
npm install inngest capskip

第 1 步:为什么识别必须放在 step 内部

Inngest 不会把你的函数从头到尾只跑一遍。它会运行函数,在第一个 step 处停下,记录结果,然后带着上一次执行的状态,再次从头调用这个函数。它自己的文档把第二遍讲得很直白:step 里的代码不会被执行,SDK 会把结果直接注入到 step.run 的返回值里。

这就是整个模型,而它有一个比其他影响都重要的后果。位于 step 之外的代码不会被记忆化,所以每次调用都会执行。一个有四个 step 的函数会调用你的 handler 四次,于是写在这些 step 上方的识别调用,在一次函数运行中会执行四次。

// npm install capskip
// WRONG. This line runs once per step boundary, so a
// four-step function solves four CAPTCHAs for one run.
const result = await solver.recaptcha(sitekey, pageUrl);

await step.run("fetch-form", async () => { /* ... */ });
await step.run("submit", async () => { /* ... */ });

Inngest 把这条规则说得很直接:任何非确定性逻辑,比如数据库调用或 API 调用,都必须放在 step.run 调用内部。识别本身就是一次 API 调用,所以它属于 step 内部。用按次计费的识别服务,这个错误会体现在账单上;用本地识别工具,它体现为四倍的工作量和四个 token,其中三个被白白丢掉。

第 2 步:在同一个 step 里完成识别和提交

第二条规则不那么显眼,而且要到后面才咬你一口。一个 step 一旦完成,它的返回值就会被存下来,并在之后的每一次调用中重放。step.run 返回的所有数据都会被序列化成 JSON,而状态是按 step id 来记忆的。

所以从识别 step 返回的 token 就是一个存好的字符串。它会在下一次调用以及再下一次调用中原样回来,而那时它可能已经是几分钟前的东西了。一个 reCAPTCHA token 的有效期大约两分钟。识别和提交之间的任何东西都会吃掉这个窗口:一次 sleep、一次缓慢的请求,或者一个带退避重试了几次的 step。它们中的任何一个都可能把时间耗光。

// WRONG. The token is memoized here and replayed later,
// by which time it has almost certainly expired.
const token = await step.run("solve", () =>
  solver.recaptcha(sitekey, pageUrl).then((r) => r.code)
);
await step.sleep("settle", "5m");
await step.run("submit", () => postForm(token));

把它们放在一起。同一个 step 完成识别和提交,只返回函数后续真正需要的东西,而那几乎从来不是 token 本身。

// RIGHT. The token is born and spent inside one step,
// so nothing expired is ever replayed.
const outcome = await step.run("solve-and-submit", async () => {
  const { code } = await solver.recaptcha(sitekey, pageUrl);
  const res = await postForm(pageUrl, code);
  return { status: res.status, id: res.id };
});

这个过期窗口和串联队列任务时遇到的是同一个,值得读一遍: reCAPTCHA token 能维持多久.

第 3 步:重试,以及哪些错误值得重试

在初次尝试之外,Inngest 还会对一个函数或一个 step 重试四次,而且每个 step.run 都有自己独立的重试计数。重试使用带抖动的指数退避。retries 选项可以设为 0 到 20 之间的任意值。

对于把识别和提交合在一起的 step,这些默认值基本合适,因为被重试的 step 会重新执行其中的代码,也就会重新识别一次。没有过期 token 会被继承下来。真正值得调整的,是哪些失败才应该获得重试。

哪种异常含义值得重试吗?
NetworkExceptionCapSkip 无法访问,或正在重启值得。重试就是为这种情况准备的
TimeoutException轮询超出了客户端自身的上限也许可以重试一次。很少值得重试四次
ApiExceptionAPI 返回了一个错误码取决于错误码。通常不值得
ValidationException参数错了,再试一次还是错不值得。抛出 NonRetriableError
import { NonRetriableError } from "inngest";
import { ValidationException } from "capskip";

const outcome = await step.run("solve-and-submit", async () => {
  try {
    const { code } = await solver.recaptcha(sitekey, pageUrl);
    return await postForm(pageUrl, code);
  } catch (err) {
    // A bad sitekey will be bad on all five attempts.
    if (err instanceof ValidationException) {
      throw new NonRetriableError(err.message);
    }
    throw err;   // everything else gets the normal backoff
  }
});

NonRetriableError 会跳过剩余的重试,直接让抛出它的那个 step 失败,对于本身就格式错误而不是运气不好的请求,这正是你想要的。如果识别工具告诉你它只是忙,而不是坏了,RetryAfterError 可以让你自己指定延迟,而不必接受默认的退避曲线。

第 4 步:完整的函数

上面所有内容合成一个文件。注意客户端是在哪里构造的:在 handler 之外,所以它每个进程只创建一次,而不是每次调用都创建,并且它不保存任何与单次运行相关的状态。

// npm install capskip
import { Inngest } from "inngest";
import { CapSkip } from "capskip";

export const inngest = new Inngest({ id: "signup-worker" });

// CAPSKIP_HOST is 127.0.0.1 locally and the solver machine
// once this app is deployed anywhere else.
const solver = new CapSkip({
  host: process.env.CAPSKIP_HOST ?? "127.0.0.1",
  port: 8080,
});

export const submitSignup = inngest.createFunction(
  { id: "submit-signup", retries: 4 },
  { event: "signup/requested" },
  async ({ event, step }) => {
    const { sitekey, pageUrl, email } = event.data;

    // One step. The token never leaves it.
    const outcome = await step.run("solve-and-submit", async () => {
      const { code } = await solver.recaptcha(sitekey, pageUrl);
      return postSignup(pageUrl, email, code);
    });

    await step.run("record", () => saveResult(email, outcome));
    return outcome;
  }
);

这个调用针对的是 reCAPTCHA v2。其他类型的写法完全一样:把 invisible 或 enterprise 设为 1,或者把 version 设为 v3 并带上一个 action,又或者改为调用 turnstile 或 geetest。完整的接口请见 Node.js 验证码识别页面.

第 5 步:你的代码究竟跑在哪里,以及这需要哪种模式

Inngest 和大多数托管自动化平台有一点不同,而这一点决定了本节的内容。你的函数并不在 Inngest 的基础设施上运行。Inngest 通过 HTTP 调用你应用上的 serve 端点,通常是 /api/inngest,你的代码在你自己的应用里执行。所以“这里能不能访问 127.0.0.1”这个问题和 Inngest 无关,完全取决于你把应用部署在了哪里。

应用部署在哪里用哪种连接模式
本地运行,连 Dev Server,就在 CapSkip 所在的机器上Local 模式。127.0.0.1 在这里确实是对的
在你自己的服务器上,或内网中的一台虚拟机上Server mode,填识别工具的内网地址
在 Vercel 或 Lambda 这类无服务器平台上Server mode,配一个固定公网 IP 加一条防火墙规则
在应用旁边的容器里,识别工具在别处Server 模式。容器内部的回环地址指向的就是容器本身

连接模式有两种。Local 模式绑定 127.0.0.1,只响应本机。Server 模式绑定你的内网地址或公网 IP,这样另一台机器、一台容器宿主机或者一个无服务器函数就能通过 API 访问同一台 Windows 机器。两者都位于 连接设置,而 Server 模式只改变识别程序监听在哪个地址上。硬件还是你自己的,识别也依然不计量。

正是不计量这一点,让一个整天不停触发的函数跑起来才算合理。

有一点容易混为一谈:Inngest Cloud 也需要能访问到你的 serve 端点,这和识别工具是两套彼此独立的网络配置。Inngest 已经能调用到的部署,并不自动等于能调用你内网的部署。

常见错误及其含义

你所看到的原因修复
一次函数运行却记录了多次识别识别写在 step.run 之外,所以每到一个边界就重复一次把它挪进 step 内部
token 识别得好好的,提交却失败了被记忆化的 token 在过期之后又被重放了在同一个 step 里完成识别和提交
某个 step 重试了四次,每次都以同样的方式失败参数错误被当成了临时性故障遇到 ValidationException 就抛出 NonRetriableError
部署之后每次运行都抛 NetworkException应用挪到了识别工具所在机器之外用 Server 模式,并在部署环境里设置 CAPSKIP_HOST
两次部署之间某个 step 的结果结构变了step 的输出会序列化成 JSON,并按 id 匹配返回值变化时,给 step 换一个新 id
自己写的轮询里冒出 CAPCHA_NOT_READY结果还没完成就被读取了交给客户端轮询,它会自己退避
ApiException 里带着 ERROR_WRONG_USER_KEY部署环境里没有设置 CAPSKIP_API_KEY在应用运行的地方设置它,然后重新部署

第六行那种情况,是人们不用客户端、自己写轮询循环时最先遇到的。那个响应里的拼写不是我们这边的笔误,因为 API 返回的就是这个写法。这里有 一篇关于 CAPCHA_NOT_READY 响应的完整说明.

常见问题

我可以从一个 step 返回 token,稍后再用吗?

可以,而且它在测试时能跑通,到了生产环境就会失败。这个值会被存下来,并在之后的每一次调用中重放,所以只要两个 step 之间夹了任何慢的东西,你提交的就是一个在函数等待期间已经过期的 token。把识别和消费 token 的代码放在同一个 step 里,返回结果本身,而不是这份凭据。

重试是重新识别一个验证码,还是复用旧的?

是重新识别一个。失败的 step 不会被记忆化,所以重试会重新执行其中的代码,也包括那次识别调用。每个 step.run 都保有自己独立的重试计数,所以某个不稳定的 step 不会把其他 step 的额度花掉。这正是合并的 step 可以放心重试、而拆开的不行的原因。

我的应用部署在 Vercel 上,它还能访问我桌上的识别工具吗?

可以,用 Server 模式。函数运行在 Vercel 的沙箱里,所以那里的回环地址指的是沙箱,而不是你的机器。在连接设置里把 CapSkip 绑定到你的公网 IP,在它前面加一条防火墙规则,只放行你预期的流量,然后在项目环境变量里设置 CAPSKIP_HOST。建议使用静态公网 IP,免得地址在你不知情时变动。

这和在 Temporal 里做有什么不同?

两者都是持久化执行,也都通过不同的路径走到了同一条规则上。Temporal 在你自己运行的 worker 里,根据事件历史重放工作流,所以那条规则来自确定性。Inngest 则是重新调用你的 HTTP 端点,并注入已记忆化的 step 结果,所以规则来自重放,加上一个会变旧的 token。对应的做法可以参考 Temporal 工作流指南.

简短版结论

把识别放进 step.run 内部,绝不要放在它上面,因为 step 之外的一切都会在每个 step 边界重新执行一遍。把识别和使用 token 的代码放在同一个 step 里,因为完成的 step 会被记忆化并重放,而一个 token 只能活大约两分钟。默认的重试次数可以保持不变,但对参数错误要抛出 NonRetriableError。从环境变量里读取 CAPSKIP_HOST,并且只要应用不在识别工具所在的那台机器上,就用 Server 模式运行 CapSkip。

在给这个函数设置并发上限之前,还有一点值得掂量:CapSkip 是一款 验证码绕过 工具,运行在你已经拥有的硬件上,所以同时能有多少次运行完成识别,上限只取决于那台机器,而不是账户余额。