Hướng Dẫn Thực Hành

Xử lý lỗi và thử lại khi giải CAPTCHA bằng Node.js

Giải CAPTCHA chạy êm trên máy dev, nhưng lên production thì kiểu gì cũng có lúc API timeout, token hết hạn, hoặc số dư cạn giữa đêm. Vấn đề thường không phải chọn sai solver — mà là chưa có tầng xử lý lỗi đúng cách. Bài này xây từng lớp cho một solver Node.js chịu lỗi tốt với CaptchaAI:

  • Phân loại lỗi thử lại được và lỗi nghiêm trọng
  • Backoff theo cấp số nhân (exponential backoff)
  • Circuit breaker tự tạm dừng khi API gặp sự cố
  • Cache token có TTL
  • Ghi log số liệu vận hành

Phân loại lỗi: lỗi có thể thử lại và lỗi nghiêm trọng

CaptchaAI trả mã lỗi qua trường request trong response JSON. Việc đầu tiên là tách hai nhóm: lỗi thử lại được (hết slot, task chưa xong) và lỗi nghiêm trọng (sai key, hết số dư, không giải được) — thử lại nhóm sau chỉ tốn thêm tiền. Đoạn code dưới định nghĩa hai class lỗi riêng để solver xử lý khác nhau ngay từ đầu:

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);
}

Giãn thời gian thử lại theo cấp số nhân (exponential backoff)

Thử lại ngay khi gặp lỗi retriable chỉ dồn thêm tải lên một API đang quá tải. Cách chuẩn là backoff theo cấp số nhân: chờ lâu hơn sau mỗi lần thất bại, cộng jitter (độ trễ ngẫu nhiên) để client không cùng retry một lúc. Hàm withRetry bọc quanh lời gọi CaptchaAI, tự tăng delay đến maxDelay, và luôn ném thẳng FatalError ra ngoài:

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;
}

Xây dựng solver chịu lỗi tốt (robust solver)

Vòng đời của một task

Ghép phân loại lỗi và backoff lại, RobustSolver xử lý trọn vòng đời một task: gửi tới in.php, polling res.php đến khi có kết quả hoặc hết maxPollTime, tự thử lại khi gặp ERROR_NO_SLOT_AVAILABLE hoặc timeout — lớp nền mọi solver production cần trước khi thêm circuit breaker hay cache:

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`);
  }
}

Ví dụ: đội QA outsourcing chạy nhiều job song song

Một đội QA outsourcing chạy regression test ban đêm — mô hình phổ biến ở các công ty phần mềm TP.HCM và Hà Nội — thường dùng chung gói CaptchaAI mức BASIC ($15/tháng, 5 thread). Nhiều job cùng gửi task một khung giờ khiến ERROR_NO_SLOT_AVAILABLE xuất hiện thường xuyên hơn — đúng lúc maxRetries và backoff phát huy tác dụng.

Circuit breaker: tạm dừng khi API gặp sự cố

Nếu CaptchaAI thật sự gặp sự cố, để RobustSolver tự thử lại từng task riêng lẻ chỉ tốn thêm thời gian chờ. Circuit breaker theo dõi số lần thất bại liên tiếp: vượt ngưỡng threshold, nó chuyển sang open và chặn request mới trong resetTimeout. Hết thời gian nghỉ, nó cho một request thử nghiệm đi qua (half-open) để kiểm tra API đã hồi phục chưa:

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;
  }
}

Xử lý token hết hạn bằng cache có TTL

Token reCAPTCHA sống khoảng 2 phút, Turnstile khoảng 5 phút — giải xong rồi mới đi làm việc khác trước khi submit, token có thể chết ngay lúc cần dùng. TokenCache gắn TTL để solver biết khi nào phải giải lại. solveWithRetryOnReject xử lý chiều ngược lại: token còn hạn nhưng bị site từ chối (thường do điểm reCAPTCHA v3 thấp) — tự giải lại tối đa maxAttempts lần:

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");
  }
}

Ghi log và đo lường số liệu vận hành

Không có số liệu, khó biết retry và circuit breaker có thật sự giúp ích hay đang che giấu vấn đề. SolverMetrics đếm số lần gửi, giải thành công/thất bại, thời gian giải trung bình và tỷ lệ thành công — đủ để đẩy vào dashboard hoặc log tập trung:

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();
  }
}

Mẫu hoàn chỉnh cho môi trường production

Ghép toàn bộ các lớp trên, solver cuối cùng chỉ còn một điểm gọi duy nhất: solver.solve(method, params). Ví dụ dưới giải 10 CAPTCHA song song bằng Promise.allSettled, để một task thất bại không kéo sập cả batch, rồi in báo cáo số liệu cùng lỗi từng task:

// 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();

Khắc phục sự cố thường gặp

Vấn đề Nguyên nhân Cách xử lý
Mọi lần thử lại thất bại ngay lập tức Lỗi fatal đang bị đưa vào vòng thử lại Kiểm tra lại logic phân loại lỗi
Circuit breaker cứ ở trạng thái open API đang gặp sự cố hoặc key sai Kiểm tra trạng thái API và API key
Token hết hạn ngay khi submit Thời gian giải cộng độ trễ điều hướng quá lâu Giải token ngay trước khi điều hướng
AbortError khi gọi fetch Timeout đặt quá ngắn Tăng giá trị AbortSignal.timeout
UnhandledPromiseRejection Thiếu catch cho lời gọi async Luôn xử lý mọi promise bị reject

Câu hỏi thường gặp

CaptchaAI trả về ERROR_CAPTCHA_UNSOLVABLE, có nên thử lại không?

Không. Đây là lỗi nghiêm trọng — CAPTCHA đó không giải được, thử lại chỉ tốn thêm tiền. Đưa mã này vào FATAL_ERRORS để solver ném lỗi ngay.

Circuit breaker có thật sự cần thiết, hay retry là đủ?

Cần, khi chạy nhiều task song song. API down mà hàng trăm task tự thử lại cùng lúc chỉ tạo thêm một cơn bão request. Circuit breaker chặn hẳn request mới trong một khoảng nghỉ để hệ thống phục hồi.

Token bị site từ chối dù giải đúng thì xử lý thế nào?

Giải lại và gửi token mới, đừng submit lại token cũ — thường gặp khi điểm reCAPTCHA v3 quá thấp. solveWithRetryOnReject xử lý đúng luồng này: thử tối đa maxAttempts lần.

Nên đặt bao nhiêu lần thử lại và bao nhiêu lần polling?

Khoảng 3 lần thử lại khi gửi task, tối đa 30 lần polling là mức hợp lý cho phần lớn use case. 3 lần gửi liên tiếp đều thất bại thường không còn là "API chậm" mà là key sai hoặc CaptchaAI đang gặp sự cố diện rộng.

Vì sao nên cache token thay vì giải lại mỗi lần cần dùng?

Vì giải CAPTCHA tốn cả thời gian lẫn thread trong gói CaptchaAI, trong khi token còn hạn vẫn dùng được nhiều lần trong TTL của nó. Cache theo TTL giúp giảm request không cần thiết khi nhiều luồng cùng cần token cho một form.

Tóm tắt

Solver Node.js chịu lỗi tốt với CaptchaAI cần đủ năm lớp: phân loại lỗi rõ ràng giữa retriable và fatal, backoff theo cấp số nhân kèm jitter, circuit breaker để không dồn tải lên API đang gặp sự cố, cache token có TTL, và số liệu để biết hệ thống chạy tốt hay không trước khi có ai report lỗi.

Bài viết liên quan

Bước tiếp theo

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