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

Решение капчи в 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 можно задать в диапазоне от нуля до двадцати.
Для объединённого шага, который решает капчу и отправляет форму, эти значения по умолчанию близки к нужным, потому что повторённый шаг заново выполняет свой код и, значит, решает капчу заново. Никакого устаревшего токена он не наследует. Настраивать стоит другое: какие именно сбои вообще получают повтор.
| Какое исключение | Что это значит | Стоит ли повторять? |
|---|---|---|
| NetworkException | CapSkip был недоступен или перезапускается | Да. Именно для этого повторы и нужны |
| TimeoutException | Опрос вышел за собственный предел клиента | Может быть, один раз. Четыре редко оправданы |
| ApiException | API вернул код ошибки | Зависит от кода ошибки. Обычно нет |
| 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 или Lambda | Server 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 внутри ApiException | CAPSKIP_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 API.
- Сама задача с галочкой разобрана на странице сервиса распознавания reCAPTCHA v2.
Что стоит взвесить до того, как выставлять лимит параллелизма для функции: CapSkip выполняет обход капчи на оборудовании, которое у вас уже есть, поэтому число одновременных решений ограничивает только эта машина, а не баланс на счету.
