На этой странице

Покупатель открывает в интернет-магазине форму обратной связи, пишет вопрос и нажимает «Отправить». Сервис капчи решает, что на этот раз нужно задание. Покупатель закрывает его, передумывает и листает ниже, чтобы оставить отзыв о товаре. Там проверка проходит. И страница отправляет форму обратной связи. Отзыв так и висит со спиннером.

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

В 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 идемпотентной на уровне страницы. Скорее всего, этот скрипт нужен на странице не только вашему коду.
  • Проверяйте значения по умолчанию на отсутствующих записях, а не только на пустых. Если фича читает настройки, запустите её на самом старом аккаунте, который найдёте.
  • Считайте «ждём проверку» отдельным состоянием со своим сценарием отмены, а не частью «запрос выполняется».
  • CAPTCHA
  • Async state
  • Forms
  • Rollout
Александр Ануфриев
Александр Ануфриев

Senior frontend-разработчик