Как решать капчу в Azure Function (C# Isolated)

azure functions captcha - How to Solve CAPTCHAs in an Azure Function (C# Isolated)

Решение капчи в Azure Functions ломается по причине, которую ваш код вам никогда не покажет. У функции с HTTP-триггером есть 230 секунд на ответ, какой бы таймаут вы ни настроили, потому что этот предел задаёт балансировщик нагрузки перед платформой. Таймаут опроса для reCAPTCHA в клиенте CapSkip по умолчанию равен 300 секундам. То есть медленное решение обрывает Azure, а не ваша функция, и в логе видно запрос, который просто закончился. Лечится это тем, что решать капчу на HTTP-запросе нужно перестать совсем. Второй момент, который важно учесть, касается loopback: приложение-функция работает на машинах Azure, а не на ваших.

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

  • Приложение-функция на изолированном воркере .NET. Поддержка in-process модели заканчивается 10 ноября 2026 года, поэтому строить стоит именно на изолированном воркере.
  • CapSkip, запущенный на машине с Windows в режиме Server, по адресу, до которого дотянется приложение-функция.
  • Учётная запись хранилища, потому что схема ниже переносит решение капчи в функцию с триггером очереди.
  • sitekey и URL страницы, которые приходят в сообщении очереди, а не зашиты в код, чтобы одна функция обслуживала любые формы.
# dotnet add package CapSkip
dotnet add package CapSkip
dotnet add package Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues

Шаг 1: loopback в приложении-функции указывает на само приложение-функцию

С этим стоит разобраться раньше всего остального, потому что от этого зависит, заработает ли всё прочее. Ваша функция выполняется на инстансе, который выделяет Azure, поэтому 127.0.0.1 внутри неё указывает на этот самый инстанс. Порт 8080 там никто не слушает, и сбой приходит как NetworkException на первом же решении после деплоя, хотя тот же код прекрасно работал под локальным инструментарием.

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

Как вы запускаете функциюКакой режим подключения
Локальный инструментарий на машине с CapSkipРежим Local. 127.0.0.1 действительно верен
Развёрнуто, решатель в сети, до которой Azure может проложить маршрутServer mode с этим приватным адресом
Развёрнуто, до решателя добираются через интернетServer mode со статическим публичным IP и правилом брандмауэра

Когда решатель стоит в сети, до которой Azure умеет строить маршрут, полезно знать о двух возможностях Azure. Интеграция с виртуальной сетью для исходящего трафика доступна на планах Flex Consumption, Premium и Dedicated и совсем недоступна на устаревшем плане Consumption. Hybrid Connections, которые как раз созданы для доступа к сервису, остающемуся в вашей собственной сети, доступны на планах Premium и Dedicated для приложений на Windows. В любом случае решатель остаётся на вашем железе, меняется только маршрут.

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

// dotnet add package CapSkip
using CapSkip;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();

// One client for the app. The host is an application
// setting, so local and deployed can differ.
builder.Services.AddSingleton(new CapSkipClient(
    host: Environment.GetEnvironmentVariable("CAPSKIP_HOST") ?? "127.0.0.1",
    port: 8080));

builder.Build().Run();

Шаг 2: стена в 230 секунд и почему это не ваш таймаут

Именно на этом теряют полдня, потому что любое число, которое видно в портале, больше того, что на самом деле убивает запрос.

Microsoft пишет об этом прямо: независимо от настройки таймаута приложения-функции, у функции с HTTP-триггером есть максимум 230 секунд на ответ на запрос, и предел этот существует из-за таймаута простоя по умолчанию в Azure Load Balancer. Поднять его нельзя ни из host.json, ни через параметр приложения, ни сменой плана.

Теперь поставьте рядом собственные числа клиента. Таймаут опроса для reCAPTCHA, Turnstile и GeeTest по умолчанию равен 300 секундам, а таймаут для картинок равен 120 секундам. То есть решение капчи-картинки укладывается в стену с запасом, а решению reCAPTCHA разрешено работать на 70 секунд дольше неё. Большинство решений заканчивается задолго до обоих чисел, и именно поэтому такой код уезжает в продакшен и падает уже на медленном хвосте.

LimitЗначениеМожно ли изменить?
Ответ HTTP, любой план230 секундНет
Таймаут опроса reCAPTCHA в клиенте300 секундДа, в конструкторе
Таймаут опроса картинок в клиенте120 секундДа, в конструкторе

Опустить таймаут опроса reCAPTCHA ниже 230 секунд всё равно стоит: клиент, который сдаётся первым, выдаёт CapSkip.TimeoutException, и это можно записать в лог, в отличие от запроса, который просто исчез. Но настоящее лечение не в этом. Настоящее лечение описано в документации Azure: использовать асинхронный шаблон Durable Functions либо отложить саму работу и сразу вернуть ответ. На практике это значит, что HTTP-триггер принимает задачу, пишет сообщение и тут же возвращается.

[Function(nameof(EnqueueSolve))]
[QueueOutput("captcha-jobs")]
public SolveRequest EnqueueSolve(
    [HttpTrigger(AuthorizationLevel.Function, "post")] SolveRequest req)
{
    // Returns in milliseconds. The solve happens on the
    // queue-triggered function, off the HTTP request.
    return req;
}

Шаг 3: у приложения-функции есть свой отдельный таймаут

Как только решение капчи ушло с HTTP-запроса, значение имеет таймаут из host.json, а он зависит от плана. Умолчания везде щедрые, кроме устаревшего плана Consumption: это единственный план, где медленное решение reCAPTCHA действительно может не уложиться.

План хостингаТаймаут по умолчаниюМаксимальный таймаут
План Flex Consumption30 минутЖёсткого максимума нет
План Premium30 минутЖёсткого максимума нет
План Dedicated30 минутЖёсткого максимума нет, при включённом Always On
План Consumption, устаревший5 минут10 минут

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

{
  "version": "2.0",
  "functionTimeout": "00:10:00"
}

Шаг 4: сколько раз сообщение очереди решается заново

Перенос решения капчи в очередь даёт запас времени, но приносит собственное поведение с повторами, которое впустую тратит реальное время, если его не трогать.

Когда функция с триггером очереди падает, Azure Functions запускает функцию для этого сообщения не более пяти раз, и первая попытка входит в эти пять. Если все пять неудачны, рантайм пишет сообщение в очередь с именем исходной и суффиксом poison. Это пять решений на одну капчу, если сбой из тех, что не пройдут никогда, например sitekey, который не принадлежит странице.

Всё плотнее, чем кажется. Параметр visibilityTimeout в host.json по умолчанию равен нулю, то есть упавшее сообщение появляется снова сразу же, и эти пять попыток успевают пройти подряд за считанные секунды. Поставьте значение, которое даст временной проблеме время рассосаться, и читайте в функции счётчик извлечений, чтобы сообщение на последней попытке обрабатывалось иначе.

Вторая половина вопроса касается параллелизма. По умолчанию триггер берёт пакет из 16 сообщений, а затем запрашивает следующие 16, как только число сообщений в работе падает до 8. Эти 8 продолжают выполняться, пока стартует новый пакет, поэтому один инстанс может вести 24 решения одновременно для одной функции. Когда приложение масштабируется, это число умножается на количество инстансов. На сервисе с оплатой за решение вы бы ограничили его, чтобы беречь баланс. Здесь речь о ёмкости одной машины с Windows, но 24 одновременных решения на инстанс всё равно стоит выбрать осознанно, а не унаследовать.

Пример ниже намеренно держится ниже обоих умолчаний: три попытки вместо пяти и пакет из восьми сообщений вместо шестнадцати.

{
  "version": "2.0",
  "extensions": {
    "queues": {
      "batchSize": 8,
      "newBatchThreshold": 4,
      "visibilityTimeout": "00:00:30",
      "maxDequeueCount": 3
    }
  }
}

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

Половина с триггером очереди: решение капчи и отправка формы в одном вызове.

// dotnet add package CapSkip
using CapSkip;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;

public class SolveCaptcha(CapSkipClient solver, ILogger<SolveCaptcha> log)
{
    [Function(nameof(SolveCaptcha))]
    public async Task Run([QueueTrigger("captcha-jobs")] SolveRequest job)
    {
        try
        {
            // Solve and submit together. The token is short lived.
            var result = await solver.RecaptchaAsync(job.Sitekey, job.PageUrl);
            await SubmitFormAsync(job.PageUrl, result.Code);
        }
        catch (CapSkip.ValidationException ex)
        {
            // A bad sitekey fails identically on all five tries.
            log.LogError("Not retryable: {Message}", ex.Message);
        }
    }
}

Этот вызов относится к reCAPTCHA v2. Остальные типы устроены так же: передайте словарь опций, где invisible или enterprise равны 1, либо version равен v3 вместе с action, либо вызовите TurnstileAsync или GeetestAsync. Полный набор возможностей описан на странице сервиса распознавания капчи для C#.

Из исключений стоит знать одно: Turnstile на странице-челлендже требует двух дополнительных значений со страницы, а также того user agent, который использовал решатель. На этот случай есть отдельное руководство.

Ошибка в параметрах здесь намеренно проглатывается, а не пробрасывается дальше. Цикл из пяти попыток запускает именно выброшенное исключение, а повтор ничем не поможет, если sitekey не подходит к странице.

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

Что вы видитеПричинаИсправить
HTTP-запрос заканчивается примерно на четвёртой минуте без ошибкиПредел балансировщика нагрузки в 230 секунд, а не ваш таймаутВозвращайте ответ сразу и решайте капчу в триггере очереди
С локальным инструментарием работает, после деплоя NetworkExceptionLoopback в приложении-функции указывает на инстанс AzureServer mode, и задайте CAPSKIP_HOST в параметрах приложения
Пять одинаковых падений, затем сообщение в очереди poisonИз функции вылетела ошибка, которую бесполезно повторятьЛовите CapSkip.ValidationException и вместо этого пишите в лог
Пять попыток сгорают меньше чем за минутуvisibilityTimeout очереди по умолчанию равен нулюЗадайте visibilityTimeout, чтобы повторы шли с интервалом
Сборка падает на неоднозначном TimeoutExceptionЭто короткое имя определено и в CapSkip, и в SystemУкажите полное имя или ловите базовый CapSkipError
ERROR_WRONG_USER_KEY внутри ApiExceptionCAPSKIP_API_KEY не задан в развёрнутом приложенииДобавьте ключ в параметры приложения и перезапустите приложение
CAPCHA_NOT_READY при самописном опросеРезультат прочитали до того, как он был готовДоверьте опрос клиенту. Он сам увеличивает интервал

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

FAQ

Может ли приложение-функция в Azure достучаться до решателя в моей собственной сети?

Да. Переключите CapSkip в режим Server в разделе настроек подключения, чтобы он слушал сетевой адрес, а не loopback, а затем укажите на этот адрес CAPSKIP_HOST в параметрах приложения. Для приватного маршрута есть интеграция с виртуальной сетью на планах Flex Consumption, Premium и Dedicated, а также Hybrid Connections на Premium и Dedicated для приложений на Windows. Если вы идёте через интернет, используйте статический публичный IP с правилом файрвола, которое разрешает только ожидаемые адреса. Сам решатель ни в одном из этих вариантов не покидает ваше железо.

Почему решение капчи на HTTP-триггере умирает примерно через четыре минуты?

Потому что потолок ответа для функции с HTTP-триггером равен 230 секундам, и берётся он от балансировщика нагрузки, а не от Functions. Ни план, ни значение в host.json, ни параметр приложения его не поднимают. Если ответ нужен в том же запросе, решение капчи должно укладываться в это окно с запасом, а значит, надо опустить таймаут опроса reCAPTCHA в клиенте с умолчания в 300 секунд и смириться с тем, что медленные решения будут падать. Ответ получше: передать работу функции с триггером очереди и сразу вернуться.

Нужны ли для этого Durable Functions?

Только если вызывающая сторона должна опрашивать результат. Durable Functions дают асинхронный HTTP-шаблон со встроенным эндпоинтом статуса, и это оправдано, когда исхода ждёт браузер или партнёрская система. Если решение капчи служит одним шагом вашего собственного конвейера, очередь хранилища проще и точно так же избавляет от предела в 230 секунд. В любом случае держите решение капчи и потребителя токена в одном вызове, потому что токен быстро истекает, а на границе оркестрации эту гонку проиграть проще всего.

Почему не компилируется мой блок catch?

Потому что клиент определяет TimeoutException и ValidationException, короткие имена которых есть и в System, а в файле функции почти всегда видны оба пространства имён. Пишите CapSkip.TimeoutException и CapSkip.ValidationException целиком либо ловите базовый CapSkipError и разбирайтесь внутри. У двух других, NetworkException и ApiException, конфликта нет, их можно ловить по короткому имени.

Коротко

Не решайте капчу на HTTP-триггере. Балансировщик обрывает запрос на 230 секундах, что бы ни было указано в functionTimeout, а таймаут reCAPTCHA в клиенте по умолчанию больше. Примите задачу, запишите сообщение в очередь, сразу верните ответ и решайте капчу в функции с триггером очереди. Разнесите повторы очереди по времени и ловите ошибки параметров, иначе один неверный sitekey сожжёт пять попыток на проблеме, которую не исправит ни один повтор. Переключите CapSkip в Server mode и пропишите его адрес в параметрах приложения, потому что loopback внутри приложения-функции указывает на инстанс Azure, а не на вашу машину.

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