Tutorials

Solução de CAPTCHA do Node.js com novas tentativas e tratamento de erros

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áveisERROR_NO_SLOT_AVAILABLE, CAPCHA_NOT_READY: vale esperar e consultar de novo.
  • FataisERROR_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é o resetTimeout (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-1 da AWS (São Paulo) reduzem o RTT até ocr.captchaai.com, dando mais folga ao maxPollTime. 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.

Artigos relacionados


Próximos passos

Os comentários estão desativados para este artigo.