Как решать капчу в Inngest и не решать её дважды

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

Решение капчи в Inngest должно жить внутри одного вызова step.run, и токен должен использоваться внутри этого же вызова. Inngest заново выполняет вашу функцию с самого начала на каждой границе шага, поэтому всё, что находится вне шага, выполняется снова каждый раз. А завершённый шаг мемоизируется, поэтому токен, решённый на первом шаге, без изменений воспроизводится на четвёртом, когда он давно истёк. Оба правила следуют из модели выполнения, а не из решателя.

Что понадобится

  • Приложение Inngest с serve-эндпоинтом, обычно по адресу /api/inngest, и Inngest Dev Server для локальных запусков.
  • CapSkip, запущенный на машине с Windows, и клиент для Node, установленный в приложении, которое обслуживает ваши функции.
  • sitekey и URL страницы, которые приходят в полезной нагрузке события, а не зашиты в функцию.
  • Режим Server всегда, когда приложение развёрнуто не на машине самого решателя, а это большинство развёртываний. Это одна настройка в разделе «Настройки подключения».
# npm install capskip
npm install inngest capskip

Шаг 1: почему решение капчи должно быть внутри шага

Inngest не выполняет вашу функцию один раз сверху вниз. Он запускает функцию, останавливается на первом шаге, записывает результат, а затем вызывает функцию заново с самого начала, приложив состояние предыдущего выполнения. Собственная документация описывает второй проход просто: код шага не выполняется, вместо этого SDK подставляет результат в возвращаемое значение step.run.

В этом и вся модель, и у неё есть одно следствие, которое важнее остальных. Код за пределами шага не мемоизируется, поэтому он выполняется при каждом вызове. Функция из четырёх шагов вызывает ваш обработчик четыре раза, поэтому решение капчи, написанное выше шагов, выполнится четыре раза за один запуск функции.

// 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, поэтому ему место внутри шага. С решателем, где платят за каждое решение, эта ошибка выливается в счёт. С локальным решателем она выливается в четырёхкратную работу и четыре токена, три из которых выбрасываются.

Шаг 2: решайте капчу и отправляйте форму в одном шаге

Второе правило менее очевидно и кусается позже. Как только шаг завершён, его возвращаемое значение сохраняется и воспроизводится при каждом следующем вызове. Все данные, возвращённые из step.run, сериализуются в JSON, а мемоизация состояния привязана к идентификатору шага.

Поэтому токен, возвращённый из шага с решением капчи, превращается в сохранённую строку. Он приходит обратно неизменным и при следующем вызове, и при том, что после него, а к тому времени ему может быть уже несколько минут. Токен reCAPTCHA живёт около двух минут. Всё, что окажется между решением и отправкой, съедает это окно: пауза, медленный запрос или шаг, который несколько раз повторился с задержкой. Любое из этого вполне может израсходовать окно до конца.

// 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));

Держите их вместе. Один шаг решает капчу и отправляет форму, а возвращает только то, что нужно остальной части функции, то есть почти никогда сам токен.

// 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.

Шаг 3: повторы и то, какие ошибки их заслуживают

Inngest повторяет функцию или шаг четыре раза сверх первой попытки, и у каждого step.run свой независимый счётчик повторов. Повторы выполняются с экспоненциальной задержкой и джиттером. Опцию retries можно задать в диапазоне от нуля до двадцати.

Для объединённого шага, который решает капчу и отправляет форму, эти значения по умолчанию близки к нужным, потому что повторённый шаг заново выполняет свой код и, значит, решает капчу заново. Никакого устаревшего токена он не наследует. Настраивать стоит другое: какие именно сбои вообще получают повтор.

Какое исключениеЧто это значитСтоит ли повторять?
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 пропускает оставшиеся повторы и роняет шаг, из которого он был брошен, а это именно то, что нужно для запроса, который был составлен неверно, а не просто попал в неудачный момент. Если решатель сообщил, что он занят, а не сломан, RetryAfterError позволяет назвать задержку самому вместо стандартной кривой.

Шаг 4: функция целиком

Всё сказанное выше в одном файле. Обратите внимание, где создаётся клиент: вне обработчика, поэтому он создаётся один раз на процесс, а не на каждый вызов, и не хранит состояния конкретного запуска.

// 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 и действие, либо вызовите 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 или LambdaServer mode со статическим публичным IP и правилом брандмауэра
В контейнере рядом с приложением, решатель в другом местеРежим Server. Loopback внутри контейнера ведёт в сам контейнер

Есть два режима подключения. Local слушает 127.0.0.1 и отвечает только этому устройству. Server слушает ваш сетевой адрес или публичный IP, поэтому другая машина, хост контейнеров или бессерверная функция может обратиться к той же машине с Windows по API. Оба режима находятся в разделе Настройки подключения, а Server mode меняет только то, по какому адресу слушает решатель. Оборудование по-прежнему ваше, и распознавание капчи по-прежнему безлимитное.

Именно отсутствие поштучной оплаты и делает разумной саму идею держать функцию, которая срабатывает весь день.

Одно легко перепутать с другим: Inngest Cloud тоже должен дотянуться до вашего serve-эндпоинта, и это отдельная часть сетевой настройки, не связанная с решателем. Развёртывание, до которого Inngest уже дозванивается, не становится автоматически развёртыванием, которое может дозвониться до вашей локальной сети.

Частые ошибки и что они означают

Что вы видитеПричинаИсправить
На один запуск функции записано несколько решений капчиРешение капчи вне step.run, поэтому оно повторяется на каждой границеПеренесите его внутрь шага
Отправка падает на токене, который решился нормальноМемоизированный токен воспроизвели после истечения срокаРешайте капчу и отправляйте форму в одном шаге
Шаг повторяется четыре раза и падает одинаковоОшибку в параметрах считают временнойБросайте NonRetriableError при ValidationException
NetworkException на каждом запуске после развёртыванияПриложение переехало с машины решателяРежим Server, и задайте CAPSKIP_HOST в развёртывании
Результат шага поменял форму между развёртываниямиВывод шага сериализуется в JSON и сопоставляется по идентификаторуПереименуйте идентификатор шага, когда меняется его возвращаемое значение
CAPCHA_NOT_READY всплывает при самодельном опросеРезультат прочитали до того, как он был готовДоверьте опрос клиенту. Он сам увеличивает интервал
ERROR_WRONG_USER_KEY внутри ApiExceptionCAPSKIP_API_KEY не задан в окружении развёртыванияЗадайте его там, где работает приложение, и разверните заново

Шестую строку люди встречают первой, когда пишут собственный цикл опроса вместо того, чтобы использовать клиент. Написание в этом ответе не наша опечатка, потому что API действительно возвращает его именно так. Полностью это разобрано в руководстве по CAPCHA_NOT_READY.

FAQ

Можно ли вернуть токен из шага и использовать его позже?

Можно, и в тестах это сработает, а в продакшене сломается. Значение сохраняется и воспроизводится при каждом последующем вызове, поэтому как только между двумя шагами окажется что-то медленное, вы отправляете токен, истёкший, пока функция ждала. Держите решение капчи и то, что расходует токен, в одном шаге, и возвращайте результат, а не сам токен.

Повтор решает новую капчу или переиспользует старую?

Новую. Упавший шаг не мемоизируется, поэтому повтор заново выполняет код внутри него, включая вызов решения капчи. У каждого step.run свой независимый счётчик повторов, поэтому один нестабильный шаг не тратит бюджет остальных. Ровно поэтому объединённый шаг безопасно повторять, а разделённый нет.

Моё приложение на Vercel. Дотянется ли оно до решателя у меня на столе?

Да, в режиме Server. Функция выполняется в песочнице Vercel, поэтому loopback там ведёт в саму песочницу, а не на вашу машину. Привяжите CapSkip к своему публичному IP в разделе «Настройки подключения», поставьте перед ним правило брандмауэра, которое пропускает только то, что вы ожидаете, и задайте CAPSKIP_HOST в окружении проекта. Статический публичный IP лучше, чтобы адрес не уезжал у вас под ногами.

Чем это отличается от того же в Temporal?

Обе системы относятся к устойчивому выполнению и приходят к одному правилу разными путями. Temporal воспроизводит рабочий процесс из истории событий внутри воркера, который запускаете вы, поэтому правило идёт от детерминизма. Inngest заново вызывает ваш HTTP-эндпоинт и подставляет мемоизированные результаты шагов, поэтому правило идёт от повторного воспроизведения и от токена, который стареет. Вариант для Temporal разобран в руководстве по рабочим процессам Temporal.

Коротко

Помещайте решение капчи внутрь step.run и никогда выше него, потому что всё, что вне шага, выполняется снова на каждой границе шага. Держите решение капчи и то, что расходует токен, в одном шаге, потому что завершённый шаг мемоизируется и воспроизводится, а токен живёт около двух минут. Оставьте повторы по умолчанию, но бросайте NonRetriableError при ошибках в параметрах. Задавайте CAPSKIP_HOST из окружения и запускайте CapSkip в режиме Server везде, где приложение работает не на машине самого решателя.

Что стоит взвесить до того, как выставлять лимит параллелизма для функции: CapSkip выполняет обход капчи на оборудовании, которое у вас уже есть, поэтому число одновременных решений ограничивает только эта машина, а не баланс на счету.