Архитектура
Один токен капчи, две ждущие формы: что сломала невидимая проверка на целой платформе
Невидимая капча выглядит как один тег script. В формах нескольких поколений она превратилась в ошибки асинхронного состояния и откат в продакшене из-за записи настроек, которой просто не было.
На этой странице
Покупатель открывает в интернет-магазине форму обратной связи, пишет вопрос и нажимает «Отправить». Сервис капчи решает, что на этот раз нужно задание. Покупатель закрывает его, передумывает и листает ниже, чтобы оставить отзыв о товаре. Там проверка проходит. И страница отправляет форму обратной связи. Отзыв так и висит со спиннером.
Ничего экзотического тут нет. Так бывает, когда результат проверки объявляют всем, кто его ждёт, а не отдают форме, которая его запросила.
В inSales, e-commerce-платформе с 60 000+ магазинов, я отвечал за frontend-часть защиты форм от ботов. Сначала перевёл поддерживаемые формы на Google reCAPTCHA Invisible (октябрь–ноябрь 2023 года). Потом добавил второй сервис, Yandex SmartCaptcha, который владелец магазина выбирает одной настройкой: активная работа началась 30 июня 2025 года, основной релиз вышел 26 августа. Клиенты просили альтернативу из-за доступности Google и вопросов к обработке данных, поэтому Google тоже остался. Снаружи всё это похоже на подключение одного скрипта. На деле получился набор проблем асинхронного состояния: формы общей библиотеки витрин, три поколения оформления заказа, два поколения личного кабинета покупателя и вход владельцев магазинов.
Невидимая не значит незаметная
Невидимый режим убирает чекбокс «Я не робот» из обычного сценария. Покупатель заполняет поля и нажимает кнопку. Форма проверяет поля, запрашивает у сервиса токен и только после этого продолжает отправку. Сервер проверяет токен и решает, принимать ли запрос.
Но «невидимая» описывает только удачный путь. Сервис всё равно может показать задание, а покупатель может его закрыть, провалить или отойти от компьютера, пока токен не истечёт. Поэтому у каждой формы, кроме «ничего не происходит» и «отправляется», есть третье состояние: ждём проверку. Большая часть ошибок жила именно там.
Токен должен знать, чей он
В ранней интеграции было общее событие «проверка завершена», и на него подписывалась каждая форма, которая запускала проверку. Вдобавок шина событий отдавала новому подписчику последние данные. Форма могла получить результат, который появился ещё до того, как она что-то запросила.
С двумя формами на странице получается ровно сцена из начала. Форма A всё ещё ждёт после закрытого задания, форма B проходит проверку, а общее событие продолжает отправку A.
// Было: кто ждёт, тот и реагирует на любой результат
eventBus.subscribe('captcha:response', ({ token }) => {
submitWithToken(pendingForm, token)
})
Исправление: перестать рассылать результат всем. Каждая проверка запускается для конкретной формы, и callback получает эту форму вместе с её токеном. По тому же правилу я сделал публичный API для кастомных форм магазинов:
function submitReview(form) {
// Если у формы уже есть токен, вернёт его.
// Иначе запустит проверку и потом вызовет submitReview(form, token).
const token = captcha.executeInvisible(form, submitReview)
if (!token) return
sendForm(form, token)
}
Изменение небольшое, но код теперь отвечает на другой вопрос. Не «прошёл ли кто-то проверку?», а «прошла ли эта форма свою проверку?». Интеграция Яндекса через два года строилась по тому же правилу.
Ждать токен и отправлять запрос — разные состояния
Пока запрос выполняется, форма блокирует повторную отправку. Ранняя обработка точно так же блокировала форму и на время ожидания капчи. Если покупатель закрывал задание, форма навсегда оставалась в состоянии отправки: кнопка неактивна, попробовать ещё раз нельзя.
Я сделал так, чтобы закрытое задание снимало ожидание, перед повторной попыткой капча сбрасывалась, а форма получала свежий токен, а не старый, который мог истечь за время паузы. Проверяли на ошибках заполнения полей, повторном открытии модального окна и повторном заказе в один клик: у каждого из этих сценариев был свой обработчик отправки.
Два загрузчика и один глобальный объект
На некоторых страницах SmartCaptcha умели подключать и системная форма платформы, и виджет витрины. Когда второй загружал скрипт повторно, сервис создавал новый window.smartCaptcha, который ничего не знал об уже отрисованных экземплярах.
Покупатель видел неработающую кнопку отправки. В консоли появлялось Widget with id … does not exist. И всё зависело от порядка загрузки. Если библиотека витрин успевала первой, системный шаблон видел готовый скрипт и не добавлял свой. При обратном порядке неверное условие в библиотеке пропускало вторую копию.
Страница не принадлежит ни одной из сторон, поэтому обе должны вести себя как гости:
let sdkReady
function loadCaptchaSdk() {
// Используем скрипт, если его уже добавила другая часть страницы
const existingScript = document.querySelector(`script[src^="${SDK_ORIGIN}"]`)
sdkReady ??= existingScript ? waitForGlobal('smartCaptcha') : injectScript(SDK_URL)
return sdkReady
}
Я поправил загрузку и в серверных шаблонах, и в библиотеке, а потом ещё раз вернулся к библиотеке и закрыл последнюю последовательность загрузки, которая всё ещё проходила.
Откат: настройка, которой не было
8 августа 2025 года первая выкатка выбора сервиса для системных форм магазинов ушла в продакшен под фича-флагом. В части существующих магазинов капча просто не появлялась. На новых тестовых аккаунтах всё работало. Выкатку откатили.
Первое, что приходит в голову, — пустое значение настройки. Оказалось хуже: у некоторых старых магазинов не было самой записи настроек. Условие ждало явного google или yandex, не получало ничего, не выбирало ни один вариант и оставляло форму без капчи.
// Было: каждый потребитель читал настройку сам
const provider = store.settings?.captchaProvider
if (provider === 'yandex') renderYandexCaptcha()
else if (provider === 'google') renderGoogleCaptcha()
// Магазин без записи настроек проходит мимо обеих веток
// Стало: у ответа один владелец и значение по умолчанию
function captchaProvider(store: Store): 'google' | 'yandex' {
return store.settings?.captchaProvider === 'yandex' ? 'yandex' : 'google'
}
Настоящее исправление было на сервере. Я добавил в модель аккаунта один метод, который возвращает тип капчи и подставляет Google, если ничего не сохранено. Существующие магазины и так работали с Google. Потом перевёл потребителей на этот метод, проверил на staging проблемный магазин и свои тестовые формы и 11 августа выпустил исправление.
Подсказка была в том, что работало. Новые тестовые аккаунты создаются с сегодняшней структурой данных, поэтому ошибку, которая живёт в данных многолетней давности, они показать не могли.
Не каждая ошибка капчи была моей. И не каждая была про капчу
Один и тот же симптом «форма не отправляется» имел разные причины: нет токена, потерян экземпляр виджета, сервер вернул ошибку, мешает блокировщик рекламы или сбоит сам сервис. Я добавил логирование событий SmartCaptcha network-error и javascript-error и собирал воспроизведения с консолью, вкладкой Network, HAR-файлом и видео.
- В Safari после прохождения задания следующий клик мог прокрутить страницу наверх. Я исправил это один раз в общей интеграции библиотеки: экземпляр SmartCaptcha уничтожается и рендерится заново. Каждую форму отдельно чинить не пришлось.
- На некоторых Android-устройствах экранная клавиатура закрывала поле задания. Изменение
viewportпомогало не везде. Проблема была внутри виджета сервиса, поэтому я сделал отдельный тестовый стенд, записал видео, описал устройства и передал всё в поддержку Yandex Cloud. - Одно обращение «капча зависает при входе» оказалось ошибкой самой формы входа. После таймера сервер отвечал, что попытки закончились, но блок ошибки этого уже не показывал. Я исправил сообщение и состояние кнопки и завёл это как отдельный баг формы входа.
Чем закончилось
К основному релизу оба сервиса работали в четырёх семействах форм библиотеки витрин, в оформлении заказа 1.0–3.0, в личных кабинетах покупателя 1.0 и 2.0 и во входе владельцев магазинов. Одна настройка магазина выбирает сервис для поддерживаемых форм, а когда проверка нужна, по-прежнему решают правила каждой формы. Разработчики магазинов получили документированный API, чтобы подключить любой из сервисов к своим формам. После релиза я продолжал сопровождение: в сентябре 2025 года исправил вход после кнопки «Назад» в браузере, а в 2026 году добавил проверку в подключение собственного домена. Весь проект описан в кейсе о защите форм.
Что я бы проверил в любой команде
- Привязывайте каждый асинхронный результат к тому, кто его запросил. Общее событие «готово» работает, пока на странице одна форма, а на реальных страницах их несколько.
- Делайте загрузку стороннего SDK идемпотентной на уровне страницы. Скорее всего, этот скрипт нужен на странице не только вашему коду.
- Проверяйте значения по умолчанию на отсутствующих записях, а не только на пустых. Если фича читает настройки, запустите её на самом старом аккаунте, который найдёте.
- Считайте «ждём проверку» отдельным состоянием со своим сценарием отмены, а не частью «запрос выполняется».