Um pipeline de CAPTCHA sem estratégia de novas tentativas falha do jeito mais caro: threads presas esperando um token que nunca chega, e chamadas repetidas contra um erro fatal que nunca vai se resolver. Este guia monta, em Node.js, uma camada de resiliência completa para a API da CaptchaAI, pronta para produção.
Você sai daqui com:
- classificação de erros recuperáveis vs. fatais
- backoff exponencial com jitter
- circuit breaker para picos de instabilidade
- cache de token para não repetir o mesmo desafio
- métricas para a saúde do pipeline
Classifique os erros do CAPTCHA antes de tentar de novo
Nem todo erro da API merece nova tentativa. Separe os dois grupos logo na entrada do código:
- Recuperáveis —
ERROR_NO_SLOT_AVAILABLE,CAPCHA_NOT_READY: vale esperar e consultar de novo. - Fatais —
ERROR_ZERO_BALANCE,ERROR_WRONG_USER_KEY,ERROR_CAPTCHA_UNSOLVABLE: nenhuma tentativa resolve.
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);
}
As classes RetriableError e FatalError herdam de CaptchaError e carregam o código original, permitindo decidir com um simples instanceof sem reler strings de erro.
Backoff exponencial: como espaçar as novas tentativas
Repetir uma chamada logo após a falha raramente ajuda, e pode até piorar erros como ERROR_NO_SLOT_AVAILABLE. O padrão withRetry dobra o intervalo a cada tentativa (baseDelay * 2^attempt), limita o teto em maxDelay e aplica jitter para instâncias do processo não acordarem juntas.
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;
}
Erros fatais pulam direto para fora do loop — não faz sentido esperar segundos para repetir algo que já sabemos que vai falhar.
Um solucionador Node.js resiliente, do envio ao polling
RobustSolver junta as duas peças: envia a tarefa para in.php com retry, e faz o polling em res.php até maxPollTime (150 s). Envio e consulta são problemas distintos, com sua própria margem de tentativas.
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 trata ERROR_NO_SLOT_AVAILABLE como caso especial, com atraso incremental simples — essa indisponibilidade costuma se resolver rápido. Timeouts de rede (TimeoutError, AbortError) seguem a mesma lógica de tentativas limitadas.
Circuit breaker: pausando as novas tentativas quando a API cai
Se a API está fora do ar, insistir a cada requisição só multiplica o problema — vários processos tentando em paralelo sem chance real de sucesso. O circuit breaker resolve isso pausando as tentativas depois de falhas repetidas.
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;
}
}
ProtectedSolver embrulha o RobustSolver com três estados:
closed— tudo normal.open— 5 falhas seguidas; rejeita na hora até oresetTimeout(60 s).half-open— uma chamada de teste decide se volta ao normal.
Cache de token: evite resolver o mesmo CAPTCHA duas vezes
Um token de reCAPTCHA vale cerca de 2 minutos; o do Turnstile, até 5. Reaproveitar um token válido evita gastar uma nova resolução, e uma nova thread, à toa. TokenCache guarda o token com TTL de 110 s, com margem sobre a validade do reCAPTCHA.
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 cobre o caso em que o site rejeita um token que parecia válido, resolvendo de novo em vez de insistir, até maxAttempts tentativas.
Métricas e logging para o solucionador de CAPTCHA em produção
Sem números, é impossível saber se uma taxa de sucesso de 92% é normal ou sinal de que algo mudou.
O que o SolverMetrics acumula
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();
}
}
Como usar o relatório
InstrumentedSolver embrulha o ProtectedSolver e já sai instrumentado — dá para expor report() num endpoint interno ou mandar ao APM (Datadog, Grafana, New Relic), sem espalhar try/catch de métricas pela aplicação.
Padrão completo de tratamento de erros para produção
Este exemplo junta todas as camadas anteriores:
- classificação de erros
- backoff exponencial com jitter
- circuit breaker
- cache de token
- métricas
Resolve 10 tarefas em paralelo com Promise.allSettled, que nunca interrompe o lote inteiro por uma falha isolada.
Rodando fora do Brasil? Workers na região
sa-east-1da AWS (São Paulo) reduzem o RTT atéocr.captchaai.com, dando mais folga aomaxPollTime. E como os logs guardam parâmetros de requisição, evite registrar o token completo ou dados pessoais — boa prática que também ajuda na conformidade com a LGPD.
// 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();
O relatório final (solver.report()) mostra quantas tarefas foram resolvidas, quantas falharam e por quê — o bastante para decidir se vale um alerta.
Problemas comuns ao lidar com erros de CAPTCHA
Antes de mexer em timeouts, cheque:
- se o erro é mesmo fatal
- se a chave e o saldo estão em dia
- se o timeout de rede é curto demais
| Sintoma | Causa | Correção |
|---|---|---|
| Todas as tentativas falham imediatamente | Erro fatal sendo tentado novamente | Verifique a classificação do erro |
| O disjuntor permanece aberto | API inativa ou chave errada | Verifique o status e a chave da API |
| O token expirou no envio | Tempo de resolução + atraso muito longo | Resolva mais perto do envio, ou reduza o TTL do cache |
AbortError ao buscar |
Tempo limite muito curto | Aumentar AbortSignal.timeout |
| Rejeição de promessa não tratada | Falta captura no assíncrono | Sempre lide com rejeições |
Perguntas frequentes
Preciso tentar novamente ERROR_CAPTCHA_UNSOLVABLE?
Não. É um erro fatal — a CaptchaAI já determinou que o desafio não tem solução. Insistir só consome tempo e créditos.
Quantas tentativas fazem sentido configurar?
Na prática, 3 tentativas de envio e até 30 consultas de polling cobrem a maioria dos casos. Se as 3 falharem, o problema está na chave de API, no saldo ou nos parâmetros — não adianta insistir.
Vale a pena cachear o token mesmo em volume baixo?
Sim, sempre que o desafio puder ser reaproveitado dentro da janela de validade. Cachear evita pagar por uma nova resolução, e uma nova thread, por um token ainda válido.
Com que limite o circuit breaker deve abrir?
Cinco falhas seguidas é um ponto de partida razoável. Contas com muitas threads simultâneas, como ADVANCE (US$ 90/mês, 50 threads) ou PREMIUM (US$ 170/mês, 100 threads), tendem a preferir um limite maior, para não travar dezenas de processos por um pico de latência passageiro.
Rodar os processos fora do Brasil muda a estratégia de novas tentativas?
A lógica de classificação é a mesma em qualquer região. Muda a margem: processos distantes da infraestrutura da CaptchaAI têm RTT mais alto — vale aumentar um pouco o AbortSignal.timeout para não confundir resposta lenta com falha de rede.
Resumo
Um pipeline de CAPTCHA pronto para produção trata erro como parte do design:
- separe o recuperável do fatal
- use backoff exponencial com jitter
- proteja com circuit breaker
- reaproveite tokens com cache
- monitore tudo com métricas
Esse é o padrão que sustenta uma integração séria com a CaptchaAI em Node.js.