Tutorials

Решение CAPTCHA Node.js с повторной попыткой и обработкой ошибок

Не всякую ошибку CaptchaAI API нужно повторять — а часть ошибок, наоборот, обязана остановить повтор сразу. ERROR_NO_SLOT_AVAILABLE — это сигнал подождать и попробовать снова. ERROR_ZERO_BALANCE — сигнал остановиться немедленно: повтор только сожжёт время и деньги на пустом балансе. Разница между этими двумя классами ошибок — и есть весь фундамент надёжной интеграции.

В продакшене падает не логика решения CAPTCHA, а всё, что вокруг неё: истёкший токен, обрыв сети посреди опроса, кратковременная нехватка свободных потоков на аккаунте. Ниже — рабочая схема на Node.js: классификация ошибок CaptchaAI, экспоненциальная задержка с джиттером, circuit breaker от каскадных сбоев, кэш токенов с TTL и метрики, которые можно завести в Prometheus/Grafana или любой другой мониторинг.


Отличаем временную ошибку от фатальной

const RETRIABLE_ERRORS = new Set([
  "ERROR_NO_SLOT_AVAILABLE",
  "CAPCHA_NOT_READY",
]);

const FATAL_ERRORS = new Set([
  "ERROR_WRONG_USER_KEY",
  "ERROR_KEY_DOES_NOT_EXIST",
  "ERROR_ZERO_BALANCE",
  "ERROR_CAPTCHA_UNSOLVABLE",
  "ERROR_BAD_DUPLICATES",
  "ERROR_BAD_PARAMETERS",
  "ERROR_WRONG_CAPTCHA_ID",
]);

class CaptchaError extends Error {
  constructor(code, message) {
    super(message || code);
    this.name = "CaptchaError";
    this.code = code;
  }
}

class RetriableError extends CaptchaError {
  constructor(code) {
    super(code, `Retriable: ${code}`);
    this.name = "RetriableError";
  }
}

class FatalError extends CaptchaError {
  constructor(code) {
    super(code, `Fatal: ${code}`);
    this.name = "FatalError";
  }
}

function classifyError(code) {
  if (FATAL_ERRORS.has(code)) throw new FatalError(code);
  throw new RetriableError(code);
}

RETRIABLE_ERRORS — временные сбои: слот занят, результат ещё не готов. FATAL_ERRORS — ошибки, которые повтором не лечатся: неверный ключ, нулевой баланс, капча в принципе нерешаема. Отдельные классы RetriableError и FatalError дают верхнему коду проверять тип ошибки через instanceof, а не парсить строку кода вручную в каждом месте вызова.


Экспоненциальная задержка между попытками

Мгновенный повтор сразу после сбоя почти всегда бьёт в ту же самую перегруженную очередь. Задержка должна расти с каждой попыткой и получать случайный разброс (jitter) — иначе десятки воркеров, упавших одновременно, повторят запрос синхронно и создадут ту же самую пиковую нагрузку ещё раз.

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

async function withRetry(fn, options = {}) {
  const {
    maxRetries = 3,
    baseDelay = 2000,
    maxDelay = 30000,
    jitter = true,
  } = options;

  let lastError;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (error instanceof FatalError) throw error;

      lastError = error;

      if (attempt < maxRetries) {
        let delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
        if (jitter) delay *= 0.5 + Math.random();
        console.log(
          `Retry ${attempt + 1}/${maxRetries} in ${(delay / 1000).toFixed(1)}s: ${error.message}`
        );
        await sleep(delay);
      }
    }
  }

  throw lastError;
}

withRetry — обёртка общего назначения: она ловит только RetriableError, пробрасывает FatalError дальше без единой лишней попытки и логирует каждую задержку, чтобы в логах было видно, сколько раз и с каким интервалом система пыталась достучаться до CaptchaAI API.


Собираем отказоустойчивый solver: submit + poll

Дальше — класс, который объединяет отправку задачи и опрос результата в один вызов solve(). Отправка (in.php) и опрос (res.php) — это две разные точки отказа, и у каждой свой сценарий повтора.

const API_KEY = "YOUR_API_KEY";

class RobustSolver {
  #apiKey;
  #maxRetries;
  #pollInterval;
  #maxPollTime;

  constructor(apiKey, options = {}) {
    this.#apiKey = apiKey;
    this.#maxRetries = options.maxRetries ?? 3;
    this.#pollInterval = options.pollInterval ?? 5000;
    this.#maxPollTime = options.maxPollTime ?? 150000;
  }

  async solve(method, params) {
    return withRetry(
      () => this.#doSolve(method, params),
      { maxRetries: this.#maxRetries }
    );
  }

  async #doSolve(method, params) {
    const taskId = await this.#submit(method, params);
    return await this.#poll(taskId);
  }

  async #submit(method, params) {
    for (let attempt = 0; attempt <= this.#maxRetries; attempt++) {
      try {
        const resp = await fetch("https://ocr.captchaai.com/in.php", {
          method: "POST",
          body: new URLSearchParams({
            key: this.#apiKey,
            method,
            json: "1",
            ...params,
          }),
          signal: AbortSignal.timeout(30000),
        });

        if (!resp.ok) {
          throw new RetriableError(`HTTP_${resp.status}`);
        }

        const data = await resp.json();

        if (data.status === 1) return data.request;

        if (data.request === "ERROR_NO_SLOT_AVAILABLE") {
          if (attempt < this.#maxRetries) {
            await sleep(3000 * (attempt + 1));
            continue;
          }
        }

        classifyError(data.request);
      } catch (error) {
        if (error instanceof FatalError) throw error;
        if (error.name === "TimeoutError" || error.name === "AbortError") {
          if (attempt < this.#maxRetries) {
            await sleep(2000 * (attempt + 1));
            continue;
          }
        }
        throw error;
      }
    }
    throw new RetriableError("MAX_SUBMIT_RETRIES");
  }

  async #poll(taskId) {
    const start = Date.now();

    while (Date.now() - start < this.#maxPollTime) {
      await sleep(this.#pollInterval);

      try {
        const resp = await fetch(
          `https://ocr.captchaai.com/res.php?${new URLSearchParams({
            key: this.#apiKey,
            action: "get",
            id: taskId,
            json: "1",
          })}`,
          { signal: AbortSignal.timeout(30000) }
        );

        const data = await resp.json();

        if (data.status === 1) return data.request;
        if (data.request === "CAPCHA_NOT_READY") continue;
        if (FATAL_ERRORS.has(data.request)) throw new FatalError(data.request);
      } catch (error) {
        if (error instanceof FatalError) throw error;
        // Network errors during poll — keep trying
        continue;
      }
    }

    throw new CaptchaError("TIMEOUT", `Timed out after ${this.#maxPollTime}ms`);
  }
}

#submit повторяет запрос при ERROR_NO_SLOT_AVAILABLE и при сетевых тайм-аутах, но пробрасывает FatalError без единого лишнего вызова. #poll трактует CAPCHA_NOT_READY как норму — так и должно быть, пока решение ещё не готово, — и обрывает опрос по #maxPollTime, если CaptchaAI не успела ответить за отведённое время.


Circuit breaker: останавливаем повторы при массовом сбое

Если API реально недоступен, тупой retry-цикл будет долбить в него сотнями запросов подряд и только продлит простой. Автоматический выключатель (circuit breaker) считает подряд идущие сбои и на пороге в несколько неудач сам «открывается» — блокирует новые попытки на заданный таймаут, вместо того чтобы гонять их вслепую.

class CircuitBreaker {
  #state = "closed"; // closed | open | half-open
  #failures = 0;
  #lastFailure = 0;
  #threshold;
  #resetTimeout;

  constructor(threshold = 5, resetTimeout = 60000) {
    this.#threshold = threshold;
    this.#resetTimeout = resetTimeout;
  }

  get state() {
    return this.#state;
  }

  canExecute() {
    if (this.#state === "closed") return true;
    if (this.#state === "open") {
      if (Date.now() - this.#lastFailure > this.#resetTimeout) {
        this.#state = "half-open";
        return true;
      }
      return false;
    }
    return true; // half-open: allow test request
  }

  recordSuccess() {
    this.#failures = 0;
    this.#state = "closed";
  }

  recordFailure() {
    this.#failures++;
    this.#lastFailure = Date.now();
    if (this.#failures >= this.#threshold) {
      this.#state = "open";
      console.log(`Circuit OPEN — pausing for ${this.#resetTimeout / 1000}s`);
    }
  }
}

class ProtectedSolver {
  #solver;
  #breaker;

  constructor(apiKey) {
    this.#solver = new RobustSolver(apiKey);
    this.#breaker = new CircuitBreaker(5, 60000);
  }

  async solve(method, params) {
    if (!this.#breaker.canExecute()) {
      throw new CaptchaError(
        "CIRCUIT_OPEN",
        "API appears down — circuit breaker is open"
      );
    }

    try {
      const result = await this.#solver.solve(method, params);
      this.#breaker.recordSuccess();
      return result;
    } catch (error) {
      if (error instanceof FatalError) throw error;
      this.#breaker.recordFailure();
      throw error;
    }
  }

  get circuitState() {
    return this.#breaker.state;
  }
}

Порог в 5 подряд идущих сбоев и минута паузы (60000) — разумная стартовая точка для большинства пайплайнов; при высоком трафике или нестабильном мобильном канале до CaptchaAI (актуально для команд, которые гоняют воркеры из региона с плавающей задержкой до внешних API) порог можно поднять, а таймаут восстановления — сократить, чтобы half-open пробовал чаще.


Кэш токенов и их время жизни (TTL)

Токен reCAPTCHA живёт около 2 минут, Turnstile — около 5. Если между получением токена и его отправкой в целевую форму проходит слишком много времени (медленная навигация, дополнительные шаги в форме), сайт отклонит уже просроченный токен. Кэш с TTL решает эту проблему на уровне приложения, а не патчами на месте использования.

class TokenCache {
  #cache = new Map();
  #defaultTTL;

  constructor(defaultTTL = 110000) {
    // reCAPTCHA: ~2 min, Turnstile: ~5 min
    this.#defaultTTL = defaultTTL;
  }

  get(key) {
    const entry = this.#cache.get(key);
    if (!entry) return null;
    if (Date.now() - entry.timestamp > this.#defaultTTL) {
      this.#cache.delete(key);
      return null;
    }
    return entry.token;
  }

  set(key, token) {
    this.#cache.set(key, { token, timestamp: Date.now() });
  }

  invalidate(key) {
    this.#cache.delete(key);
  }
}

class CachedSolver {
  #solver;
  #cache;

  constructor(apiKey) {
    this.#solver = new ProtectedSolver(apiKey);
    this.#cache = new TokenCache(110000);
  }

  async getToken(cacheKey, method, params) {
    const cached = this.#cache.get(cacheKey);
    if (cached) return cached;

    const token = await this.#solver.solve(method, params);
    this.#cache.set(cacheKey, token);
    return token;
  }

  async solveWithRetryOnReject(method, params, submitFn, maxAttempts = 2) {
    for (let i = 0; i < maxAttempts; i++) {
      const token = await this.#solver.solve(method, params);
      const accepted = await submitFn(token);
      if (accepted) return token;
      console.log(`Token rejected (attempt ${i + 1}), re-solving...`);
    }
    throw new CaptchaError("TOKEN_REJECTED", "Token rejected after max attempts");
  }
}

solveWithRetryOnReject закрывает ещё один частый случай: целевой сайт принял токен формально, но отклонил его при отправке формы (истёк, уже использован, не совпадает с текущей сессией). Вместо падения с ошибкой solver просто решает CAPTCHA заново — до maxAttempts раз.


Метрики: что снимать и куда отдавать

Без метрик первым сигналом деградации станет жалоба пользователя, а не дашборд. SolverMetrics собирает четыре числа, которых достаточно для базового мониторинга: сколько задач отправлено, сколько решено, сколько провалено и сколько было повторов.

class SolverMetrics {
  #startTime = Date.now();
  #solveTimes = [];
  #counts = { submitted: 0, solved: 0, failed: 0, retries: 0 };

  recordSubmit() { this.#counts.submitted++; }
  recordSolved(duration) { this.#counts.solved++; this.#solveTimes.push(duration); }
  recordFailed() { this.#counts.failed++; }
  recordRetry() { this.#counts.retries++; }

  report() {
    const elapsed = (Date.now() - this.#startTime) / 1000;
    const total = this.#counts.solved + this.#counts.failed;
    const avgTime = this.#solveTimes.length > 0
      ? this.#solveTimes.reduce((a, b) => a + b, 0) / this.#solveTimes.length / 1000
      : 0;

    return {
      elapsed: `${elapsed.toFixed(0)}s`,
      submitted: this.#counts.submitted,
      solved: this.#counts.solved,
      failed: this.#counts.failed,
      retries: this.#counts.retries,
      avgSolveTime: `${avgTime.toFixed(1)}s`,
      successRate: total > 0 ? `${((this.#counts.solved / total) * 100).toFixed(1)}%` : "N/A",
      throughput: `${(this.#counts.solved / (elapsed / 60)).toFixed(1)}/min`,
    };
  }
}

class InstrumentedSolver {
  #solver;
  #metrics;

  constructor(apiKey) {
    this.#solver = new ProtectedSolver(apiKey);
    this.#metrics = new SolverMetrics();
  }

  async solve(method, params) {
    this.#metrics.recordSubmit();
    const start = Date.now();

    try {
      const token = await this.#solver.solve(method, params);
      this.#metrics.recordSolved(Date.now() - start);
      return token;
    } catch (error) {
      this.#metrics.recordFailed();
      throw error;
    }
  }

  report() {
    return this.#metrics.report();
  }
}

report() возвращает обычный объект — его удобно раз в минуту логировать в stdout или экспортировать в собственный формат метрик Prometheus, если в команде уже развёрнут self-hosted Grafana. Для команд без своего мониторинг-стека тот же объект подойдёт и для отправки в любой внешний APM.


Собираем всё вместе: production-паттерн

// Combine everything
const solver = new InstrumentedSolver("YOUR_API_KEY");

async function main() {
  const tasks = Array.from({ length: 10 }, (_, i) => ({
    method: "userrecaptcha",
    params: { googlekey: `KEY_${i}`, pageurl: `https://example.com/${i}` },
  }));

  const results = await Promise.allSettled(
    tasks.map((task) => solver.solve(task.method, task.params))
  );

  const solved = results.filter((r) => r.status === "fulfilled");
  const failed = results.filter((r) => r.status === "rejected");

  console.log(`Solved: ${solved.length}, Failed: ${failed.length}`);
  console.log("Metrics:", solver.report());

  for (const fail of failed) {
    console.log(`  Error: ${fail.reason.message}`);
  }
}

main();

Всё вместе: InstrumentedSolver уже включает classification, retry, circuit breaker и метрики. Promise.allSettled запускает 10 задач параллельно и не роняет весь батч из-за одной неудачной CAPTCHA — каждая задача успевает либо решиться, либо провалиться независимо от соседних.


Диагностика типичных проблем

Симптом Причина Что делать
Все повторы падают мгновенно Фатальная ошибка попала в цикл повторов Проверьте классификацию — FatalError не должен уходить в retry
Circuit breaker завис в состоянии open API недоступен или ключ неверный Проверьте статус API и правильность ключа в панели
Токен истёк к моменту отправки формы Решение + навигация заняли дольше TTL токена Решайте CAPTCHA непосредственно перед отправкой, не заранее
AbortError при запросе Слишком короткий тайм-аут Увеличьте AbortSignal.timeout
UnhandledPromiseRejection в логах Где-то в асинхронном коде нет catch Оборачивайте вызовы solve() в try/catch или обрабатывайте через .catch()

Частые вопросы про retry и обработку ошибок CaptchaAI

Сколько раз стоит повторять запрос при решении CAPTCHA?

Разумный старт — 3 попытки отправки и до 30 опросов результата (при интервале 5 секунд это около 2,5 минут ожидания). Если 3 попытки отправки подряд не проходят, дело не в кратковременном сбое — проверяйте ключ и баланс, а не увеличивайте счётчик повторов.

Нужно ли повторять попытку при ERROR_CAPTCHA_UNSOLVABLE?

Нет. Это фатальная ошибка: CAPTCHA нельзя решить в принципе, и повтор только тратит время и потоки впустую. Классифицируйте её как FatalError и пробрасывайте сразу.

Как кэшировать токен reCAPTCHA или Turnstile, чтобы не решать CAPTCHA заново без необходимости?

Кэшируйте токен по логическому ключу (например, по URL страницы) с TTL чуть меньше реального времени жизни токена — около 110 секунд для reCAPTCHA. Как только форма отклонила токен, сразу вызывайте invalidate(), а не ждите естественного истечения TTL в кэше.

При каком количестве подряд идущих сбоев стоит открывать circuit breaker?

5 сбоев подряд и минута паузы — рабочая точка отсчёта для большинства нагрузок. Если воркеры распределены по нескольким регионам с разным качеством канала, порог стоит поднять, чтобы кратковременная деградация сети в одном регионе не блокировала остальные.

Подходит ли этот паттерн retry для Turnstile, GeeTest v3 и BLS, а не только для reCAPTCHA?

Да. Классификация ошибок, backoff, circuit breaker и кэш токенов работают на уровне in.php/res.php и не зависят от конкретного типа CAPTCHA — меняется только параметр method и, для потокового кэша, ожидаемое время жизни токена конкретного типа.


Итоги

Надёжное решение CAPTCHA в Node.js с CaptchaAI строится на пяти слоях: классификация ошибок на повторяемые и фатальные, экспоненциальная задержка с джиттером, circuit breaker от каскадных сбоев, кэш токенов с TTL под конкретный тип CAPTCHA и метрики, которые показывают деградацию раньше, чем на неё пожалуются пользователи.

Похожие статьи


Следующие шаги

Комментарии для этой статьи отключены.