Как решать капчу в задаче очереди Laravel (PHP SDK)

laravel queue captcha - How to Solve CAPTCHAs in a Laravel Queue Job (PHP SDK)

Чтобы решить капчу в задаче очереди Laravel, вызовите PHP-клиент CapSkip внутри метода handle задачи и отправьте токен из той же задачи. Сам вызов короткий. Внимания требуют три числа, с которыми Laravel поставляется в расчёте на задачи, завершающиеся за секунды: воркер даёт каждой задаче 60 секунд, очередь передаёт задачу другому воркеру через 90, и каждая задача получает одну попытку. Решение reCAPTCHA может опрашивать результат до 300 секунд, поэтому при настройках по умолчанию задача с капчей в очереди Laravel либо убивается на полпути, либо помечается как проваленная, пока ещё решает, а если добавить повторы, она может выполниться дважды. В этом руководстве разобраны сама задача, таймауты, правило повторов и то единственное, что меняется, когда воркер работает на Windows.

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

  • Laravel 11 или новее. Задача использует единый трейт Queueable, появившийся в 11; всё остальное здесь работает и на 10. В Laravel 13 настройки задачи можно также задавать атрибутами вроде #[Tries(3)], а обычные свойства, которые используются ниже, там по-прежнему работают.
  • PHP-пакет CapSkip, которому нужен PHP 8.0 или новее с расширениями curl и json. Проверьте, что curl включён в том php.ini, который на самом деле загружает ваш воркер, потому что на Windows его строка иногда остаётся закомментированной.
  • Соединение очереди. Новые приложения Laravel используют драйвер database, и примеры ниже тоже.
  • CapSkip, запущенный на машине с Windows. В режиме Local он отвечает на 127.0.0.1 и только для этой машины, что подходит для воркера на том же ПК. В режиме Server он слушает ваш сетевой адрес или публичный IP, так что приложение Laravel на Linux-сервере, в Forge или на любом другом хосте может обращаться к нему через API. Оба режима задаются в разделе Настройки подключения.
# Run in the Laravel project root
composer require capskip/capskip

Шаг 1: регистрируем клиент в контейнере

Храните адрес решателя в конфигурации, а не в задаче, чтобы один и тот же код работал и с локальным решателем, и с серверным. Добавьте блок в config/services.php и привязку singleton в свой сервис-провайдер.

// config/services.php
'capskip' => [
    'key' => env('CAPSKIP_API_KEY', 'capskip'),
    'host' => env('CAPSKIP_HOST', '127.0.0.1'),
    'port' => (int) env('CAPSKIP_PORT', 8080),
],

// app/Providers/AppServiceProvider.php, inside register()
$this->app->singleton(\CapSkip\CapSkip::class, fn () => new \CapSkip\CapSkip([
    'apiKey' => config('services.capskip.key'),
    'host' => config('services.capskip.host'),
    'port' => config('services.capskip.port'),
]));

Клиент никогда сам не читает переменные окружения, поэтому привязка передаёт каждое значение явно. Во всех остальных местах приложения читайте их через config(), а не через env(): после запуска config:cache вызов env() вне конфигурационных файлов возвращает null.

Шаг 2: решение и отправка в одной задаче

Укажите клиент как тип параметра в handle(), и Laravel внедрит singleton. Задача считывает sitekey со страницы, решает капчу и отправляет форму.

<?php

namespace App\Jobs;

use CapSkip\CapSkip;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;

class SubmitSignup implements ShouldQueue
{
    use Queueable;

    public function __construct(public string $pageUrl, public array $fields) {}

    public function handle(CapSkip $solver): void
    {
        $html = Http::timeout(30)->get($this->pageUrl)->throw()->body();
        preg_match('/data-sitekey="([^"]+)"/', $html, $m);

        // Solve and submit in one job: the token expires in two minutes.
        $token = $solver->recaptcha($m[1], $this->pageUrl)['code'];

        Http::asForm()->timeout(30)
            ->post($this->pageUrl, $this->fields + ['g-recaptcha-response' => $token])
            ->throw();
    }
}

Ставьте задачу в очередь вместе со страницей и остальными полями формы, на её собственное соединение и в её собственную очередь, которые определяет следующий шаг:

SubmitSignup::dispatch('https://example.com/signup', ['email' => 'YOUR_EMAIL'])
    ->onConnection('captcha')
    ->onQueue('captcha');

Держите решение и отправку вместе. Токен reCAPTCHA действителен около двух минут, а токен, переданный второй задаче в цепочке, может потратить это время на ожидание за другой работой. Статья о сроке жизни токена reCAPTCHA подробно разбирает этот таймер. PHP-клиент синхронный, поэтому процесс воркера занят всё время решения; чтобы запускать решения в Laravel бок о бок, нужно больше процессов воркеров, а не другой клиент.

Шаг 3: выстраиваем таймауты по порядку

Для задачи с капчей в очереди Laravel тикают четыре таймера. Изнутри наружу:

  • Собственные настройки CapSkip. Задание reCAPTCHA может ждать свободного потока до 250 секунд (Wait Timeout) и получает 250 секунд на решение (Row Timeout). Когда истекает любой из них, CapSkip завершает задание с ошибкой, и клиент выбрасывает ApiException, если только раньше не сработал собственный лимит клиента, описанный ниже.
  • Опрос в клиенте. recaptcha() опрашивает результат не дольше recaptchaTimeout, по умолчанию 300 секунд, а затем выбрасывает TimeoutException. Вместе с двумя HTTP-вызовами вокруг него, каждый из которых в коде выше ограничен 30 секундами, весь handle() завершается в пределах примерно шести минут, если CapSkip отвечает нормально. Однако срок в 300 секунд проверяется между опросами, а каждый запрос клиента может висеть до 120 секунд, поэтому CapSkip, который перестал отвечать, может растянуть задачу дольше этого срока.
  • Таймаут задачи. Опция timeout у воркера по умолчанию равна 60 секундам, и если задача выполняется дольше, процесс её воркера убивается. Задайте задаче $timeout больше тех шести минут, о которых сказано выше.
  • retry_after. Каждое соединение очереди возвращает зарезервированную задачу в очередь, если она выполняется дольше этого срока, по умолчанию 90 секунд. Если у задачи остались попытки, медленное решение, которое ещё идёт на одном воркере, запускается заново на другом, а это означает второе решение и вторую отправку формы. Если попыток не осталось, второй воркер сразу помечает задачу как проваленную, хотя первый ещё может успеть отправить форму.

В документации Laravel сказано, что таймаут всегда должен быть хотя бы на несколько секунд короче retry_after. Поэтому порядок такой: сначала клиент, затем таймаут задачи, последним retry_after. Выделите задачам с капчей собственное соединение, чтобы долгий retry_after не замедлял восстановление всех остальных задач приложения:

// config/queue.php, under 'connections'
'captcha' => [
    'driver' => 'database',
    'connection' => env('DB_QUEUE_CONNECTION'),
    'table' => env('DB_QUEUE_TABLE', 'jobs'),
    'queue' => 'captcha',
    // Longer than the job's timeout, so no second worker takes it.
    'retry_after' => 420,
    'after_commit' => false,
],

// On the job class
public $timeout = 390;

Запустите воркер на этом соединении и этой очереди:

php artisan queue:work captcha --queue=captcha

$timeout задачи имеет приоритет над собственной опцией timeout воркера, поэтому в команде она не нужна.

Шаг 4: повторяйте только то, что исправит повтор

Если не указать иное, задача получает одну попытку, так что любое исключение проваливает её окончательно. Некоторые сбои капчи стоят ещё одной попытки: результат ERROR_CAPTCHA_UNSOLVABLE, TimeoutException или NetworkException из-за того, что CapSkip перезапускался. Другие при повторе не пройдут никогда: неверный API-ключ, некорректный sitekey (ERROR_GOOGLEKEY) или параметр, который отклоняет клиент. Sitekey, который отклоняет Google, возвращается как ERROR_CAPTCHA_UNSOLVABLE, поэтому он расходует повторы, как любой другой нерешаемый результат. Разрешите три попытки, разнесите их во времени, а безнадёжные случаи проваливайте сразу.

use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\ValidationException;

// On the job class
public $tries = 3;
public $backoff = [30, 120];

// Inside handle(), around the solve
try {
    $token = $solver->recaptcha($m[1], $this->pageUrl)['code'];
} catch (ValidationException $e) {
    $this->fail($e);   // a bad parameter will not fix itself
    return;
} catch (ApiException $e) {
    if (! str_contains($e->getMessage(), 'UNSOLVABLE')) {
        $this->fail($e);   // a wrong API key and the like
        return;
    }
    throw $e;   // retried after the backoff
}

$this->fail() marks the job failed without spending the remaining attempts, and anything rethrown is retried after 30 seconds, then 120. A job killed by its timeout also uses up an attempt, but it is picked up again only when retry_after expires, not after the backoff. A 4xx from the form also throws, and the retry solves and posts again. If the site answers 4xx for bad input, catch Illuminate\Http\Client\RequestException around the post and call $this->fail($e) when $e->response->clientError() is true.

Запуск воркера на Windows

Запустить Laravel на том же ПК с Windows, что и CapSkip, проще всего, но у такой схемы есть одна ловушка. Laravel обеспечивает таймауты задач через расширение pcntl, а на Windows pcntl не существует. Там эта команда выводит bool(false):

php -r "var_dump(extension_loaded('pcntl'));"

Без pcntl и $timeout задачи, и опция timeout воркера молча игнорируются, и задача выполняется, пока не вернёт управление, сколько бы это ни заняло. retry_after по-прежнему действует, потому что его проверяет следующий воркер, когда забирает задачу, и сигнал для этого не нужен. Так что на Windows медленную задачу в Laravel не останавливает ничто. Решение завершают собственные Wait Timeout и Row Timeout в CapSkip и 300-секундный лимит опроса в клиенте, поэтому не отключайте их. Держите retry_after выше самого долгого возможного времени задачи: 420 покрывает CapSkip, который отвечает нормально, а 660 покрывает ещё и зависшее соединение, например в режиме Server через интернет.

Отличаются ещё две вещи. Laravel Horizon требует расширений pcntl и posix, поэтому на Windows он не установится; используйте там обычный queue:work или запускайте Horizon на Linux-хосте, который обращается к CapSkip в режиме Server. Кроме того, на Windows нет Supervisor, поэтому запускайте каждый воркер через Планировщик заданий или через обёртку-службу, которая его перезапускает. На Linux stopwaitsecs в Supervisor должен быть больше самой долгой задачи, иначе деплой убьёт решение на полпути. В обоих случаях после деплоя или изменения .env выполните php artisan config:cache, если кешируете конфигурацию, а затем php artisan queue:restart, потому что воркер держит ту конфигурацию, с которой запустился.

Каждый процесс воркера решает одну капчу за раз, а CapSkip по умолчанию выполняет до 10 решений reCAPTCHA одновременно (Max. Threads в его настройках reCAPTCHA). Если на очереди captcha больше примерно десяти воркеров, задачи лишь ждут внутри CapSkip, где это время засчитывается в Wait Timeout.

Полный рабочий пример

<?php

namespace App\Jobs;

use CapSkip\CapSkip;
use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\ValidationException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;

class SubmitSignup implements ShouldQueue
{
    use Queueable;

    public $tries = 3;
    public $backoff = [30, 120];
    // GET 30 s + solve up to 300 s + POST 30 s, plus a margin.
    public $timeout = 390;

    public function __construct(public string $pageUrl, public array $fields) {}

    public function handle(CapSkip $solver): void
    {
        $html = Http::timeout(30)->get($this->pageUrl)->throw()->body();
        if (! preg_match('/data-sitekey="([^"]+)"/', $html, $m)) {
            $this->fail(new RuntimeException("No sitekey on {$this->pageUrl}"));
            return;
        }

        try {
            $token = $solver->recaptcha($m[1], $this->pageUrl)['code'];
        } catch (ValidationException $e) {
            $this->fail($e);
            return;
        } catch (ApiException $e) {
            if (! str_contains($e->getMessage(), 'UNSOLVABLE')) {
                $this->fail($e);
                return;
            }
            throw $e;
        }

        // Submit in the same job, while the token is still valid.
        Http::asForm()->timeout(30)
            ->post($this->pageUrl, $this->fields + ['g-recaptcha-response' => $token])
            ->throw();
    }

    public function failed(?Throwable $exception): void
    {
        logger()->warning('Signup gave up', [
            'url' => $this->pageUrl,
            'error' => $exception?->getMessage(),
        ]);
    }
}

Используйте эту задачу вместе с соединением captcha из шага 3 и привязкой из шага 1, ставьте её в очередь, как показано в шаге 2, и запустите воркер командой из шага 3. Шаблон для sitekey ожидает атрибут data-sitekey в двойных кавычках, как его пишет собственный сниппет reCAPTCHA. Для виджета invisible или Enterprise передайте соответствующую опцию в recaptcha(), как описано на странице сервиса распознавания reCAPTCHA v2; сырой эндпоинт, который стоит за вызовом, описывает справочник API.

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

Что вы видитеПричинаИсправить
"has timed out" через 60 секунд, и задача помечена как проваленнаяТаймаут воркера по умолчанию равен 60 секундам, а у задачи одна попыткаЗадайте задаче $timeout, а если нужны повторы, то и $tries
Форма отправляется дважды, или для одной задачи идут два решенияЗадача выполнялась дольше retry_after (по умолчанию 90 секунд), и её забрал второй воркерВынесите задачи с капчей на соединение, у которого retry_after больше $timeout
На Windows задача выполняется намного дольше своего $timeoutТаймаутам задач нужен pcntl, которого нет на WindowsПолагайтесь на собственные таймауты CapSkip и 300-секундный лимит клиента и держите retry_after выше длительности всей задачи
"has been attempted too many times"Задачу подхватывали заново, после того как её воркер умирал или она выходила за retry_after, чаще, чем позволяет $triesИщите воркеры, которые деплой убил посреди задачи, и задачи, которые живут дольше retry_after
ApiException с ERROR_CAPTCHA_UNSOLVABLECapSkip завершил задание с ошибкой, например когда оно вышло за Row TimeoutПробросьте его дальше, чтобы Laravel повторил задачу после паузы backoff
ApiException с ERROR_KEY_DOES_NOT_EXISTВ CapSkip включена проверка API-ключа, и CAPSKIP_API_KEY не совпадает ни с одним ключом тамИсправьте ключ и перезапустите воркеры; проваливайте задачу, а не повторяйте её
NetworkException на первом решении после изменения .envВоркер всё ещё работает со старым хостом, или CapSkip не запущенВыполните php artisan config:cache, если кешируете конфигурацию, затем php artisan queue:restart и проверьте режим: Local или Server
Call to undefined function curl_init()Расширение curl выключено в php.ini, который загружает воркерВключите extension=curl в этом php.ini
Запрос страницы падает по таймауту, пока задача "выполняется"Задачу поставили в очередь без соединения captcha, а QUEUE_CONNECTION равен sync, поэтому она выполнилась внутри веб-запросаСтавьте задачу на соединение captcha, как в шаге 2, и запустите на нём воркер
Сайт отклоняет токенЕго отправили после истечения срока действия или не на тот URLОтправляйте из той же задачи сразу после решения, на URL из атрибута action формы

FAQ

Почему бы не решать капчу прямо в контроллере?

Потому что решение занимает десятки секунд, а может занять и несколько минут. Это дольше, чем готов ждать посетитель, и дольше 30-секундного max_execution_time, который большинство установок PHP дают веб-запросу. Если поставить задачу в очередь, контроллер сразу вернёт ответ, а медленная часть выполнится в процессе воркера, у которого такого лимита нет.

Работает ли задача с капчей в очереди Laravel под Horizon?

Да, на хосте с pcntl, то есть на Linux или macOS. Horizon задаёт таймаут для каждого супервизора в config/horizon.php, поэтому дайте супервизору, который обслуживает очередь captcha, таймаут чуть больше 390 секунд, заданных задаче, например 400, потому что при стратегии балансировки auto Horizon, сокращая число процессов, останавливает воркеры, которые работают дольше его собственного таймаута. Держите retry_after у соединения Redis выше этого таймаута (подойдёт 420) и направьте CAPSKIP_HOST на машину с Windows, где CapSkip работает в режиме Server.

Может ли приложение Laravel на Linux-сервере использовать CapSkip на моём ПК с Windows?

Да. Переведите CapSkip в режим Server в настройках подключения, чтобы он слушал сетевой адрес, задайте CAPSKIP_HOST в .env приложения и перезапустите воркеры. Если маршрут идёт через интернет, используйте статический публичный IP и правило файрвола, которое пропускает только ваш сервер, и включите проверку API-ключа. Решатель остаётся на вашей собственной машине с Windows, поэтому в подсчёте решений ничего не меняется.

Чем это отличается от Hangfire или Celery?

Схема везде одинакова: решайте и отправляйте в одной задаче, повторяйте только те сбои, которые исправит повтор, и подгоняйте число воркеров под число потоков CapSkip. Отличается лишь то, какая настройка по умолчанию вас подведёт. Laravel даёт одну попытку и передаёт медленную задачу второму воркеру, который её проваливает или, если вы разрешили повторы, запускает снова; Hangfire повторяет десять раз на протяжении нескольких часов. Версия для .NET разобрана в руководстве по задачам Hangfire с капчей.

Коротко

Для задачи с капчей в очереди Laravel привяжите клиент CapSkip в контейнере, а решение и отправку выполняйте в одной и той же задаче. Дайте задачам с капчей собственное соединение с retry_after в 420 секунд, задайте $timeout равным 390 и $tries равным 3 вместе с backoff и проваливайте ошибки, которые повтор не исправит. На Windows помните, что без pcntl никакой таймаут не соблюдается, и перезапускайте воркеры после каждого деплоя.

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