Не всякую ошибку 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 и метрики, которые показывают деградацию раньше, чем на неё пожалуются пользователи.