Tutorials

Solve CAPTCHA Node.js dengan Retry dan Error Handling

Kode yang menyelesaikan CAPTCHA di laptop Anda dan kode yang bertahan di produksi adalah dua hal berbeda. Bedanya bukan pada cara memanggil API, melainkan pada apa yang terjadi ketika panggilan gagal — dan di produksi, panggilan pasti gagal cepat atau lambat. Artikel ini menyusun pola konkret untuk Node.js + CaptchaAI: memilah error retriable dari fatal, exponential backoff dengan jitter, circuit breaker, cache token, dan metrik. Semua potongan kode di bawah bisa Anda gabungkan menjadi satu solver produksi di bagian akhir.

Sebelum masuk ke kode, kenali empat kelas kegagalan yang akan Anda hadapi, karena tiap kelas menuntut penanganan berbeda:

  • Error dari API — saldo habis, kunci salah, atau CAPTCHA tidak terselesaikan.
  • Error jaringan — koneksi putus atau batas waktu tercapai sebelum respons datang.
  • Kapasitas penuh — semua thread pada paket Anda sedang sibuk.
  • Token kedaluwarsa — token sudah basi sebelum sempat dikirim ke form target.

Menyeragamkan keempatnya dengan satu blok try/catch adalah sumber bug produksi yang paling umum.

Klasifikasikan error: mana yang layak di-retry, mana yang fatal

Langkah pertama sebelum menulis logika retry adalah memisahkan error sementara dari error permanen. Mencoba ulang ERROR_ZERO_BALANCE sia-sia — saldo tidak akan terisi di percobaan kedua. Sebaliknya, ERROR_NO_SLOT_AVAILABLE bersifat sementara dan biasanya selesai setelah jeda singkat. Buat dua himpunan yang tegas, lalu turunkan kelas error khusus agar sisa kode bisa membedakannya lewat instanceof.

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

Aturan sederhananya: default-nya retry, kecuali kode error jelas fatal. Error baru yang belum Anda kenal tidak langsung mematikan alur, tapi kesalahan konfigurasi seperti kunci API salah berhenti seketika.

Exponential backoff dengan jitter

Kalau semua worker mencoba ulang pada interval yang persis sama, mereka menabrak API serempak dan memperparah keadaan. Solusinya jeda yang meningkat eksponensial (exponential backoff): tunggu 2 detik, lalu 4, lalu 8, dibatasi maxDelay. Jitter — pengacakan kecil pada tiap jeda — memecah kawanan retry supaya beban tersebar rata, bukan menumpuk di detik yang sama.

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

Perhatikan if (error instanceof FatalError) throw error; di awal blok catch — error fatal langsung dilempar, jadi Anda tidak membuang tiga siklus backoff untuk sesuatu yang tak mungkin pulih.

Solver yang tahan banting untuk produksi

Sekarang gabungkan klasifikasi error dan backoff ke dalam satu kelas solver. Alurnya mengikuti pola empat langkah standar CaptchaAI: kirim task ke in.php, simpan task ID, polling ke res.php, lalu pakai token. Setiap fetch dibungkus AbortSignal.timeout agar koneksi yang menggantung tidak menahan worker selamanya — relevan untuk deployment di region seperti ap-southeast-1 (Singapura) atau ap-southeast-3 (Jakarta), tempat latensi jaringan seluler bisa fluktuatif.

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

Kunci desainnya: fase submit dan fase polling punya strategi retry sendiri.

Fase submit vs fase polling

  • Saat submit, ERROR_NO_SLOT_AVAILABLE berarti semua thread sedang sibuk, jadi jeda lalu coba lagi.
  • Saat polling, CAPCHA_NOT_READY bukan error sama sekali — itu sinyal normal bahwa hasil belum siap, jadi cukup lanjutkan loop tanpa menghitungnya sebagai kegagalan.

Circuit breaker: berhenti menghantam API yang sedang bermasalah

Retry bagus untuk kegagalan sesekali, tapi berbahaya saat layanan benar-benar down. Tanpa rem, ratusan worker akan terus mencoba ulang dan menumpuk beban. Circuit breaker memutus rantai itu lewat tiga status:

  • closed — semua permintaan lewat seperti biasa.
  • open — setelah sejumlah kegagalan beruntun, permintaan langsung ditolak selama jeda pemulihan.
  • half-open — satu permintaan uji diizinkan lewat untuk mengecek apakah layanan sudah pulih.
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;
  }
}

Catat: error fatal tidak menaikkan penghitung kegagalan circuit breaker — kunci API salah adalah masalah konfigurasi, bukan indikasi layanan down, jadi tak boleh membuka sirkuit.

Menangani token yang kedaluwarsa

Token CAPTCHA punya masa berlaku pendek — token reCAPTCHA umumnya sekitar dua menit dan Turnstile sekitar lima menit. Jika Anda menahannya terlalu lama sebelum dikirim ke form target, token keburu basi. Cache singkat mencegah penyelesaian ganda, sementara pola re-solve jadi jaring pengaman saat server target menolak token.

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

TTL cache sengaja lebih pendek (110 detik) dari masa berlaku token sebenarnya, memberi ruang untuk latensi jaringan sehingga Anda tak pernah mengirim token yang hampir kedaluwarsa.

Logging dan metrik untuk visibilitas produksi

Solver yang tangguh tanpa metrik ibarat terbang tanpa instrumen. Angka yang perlu Anda pantau:

  • tingkat keberhasilan — sinyal paling awal saat ada yang memburuk.
  • rata-rata waktu penyelesaian — untuk mengatur maxPollTime yang wajar.
  • throughput per menit — untuk tahu apakah paket thread Anda kekecilan.
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();
  }
}

Kirim report() ke sistem observabilitas Anda secara berkala; tren successRate yang menurun perlahan lebih layak diselidiki daripada satu kegagalan tunggal.

Menggabungkan semuanya jadi satu pola produksi

Potongan terakhir menyatukan seluruh lapisan — instrumentasi, circuit breaker, retry, dan solver — lalu menjalankan sepuluh task secara paralel dengan Promise.allSettled. Pemakaian allSettled (bukan all) itu disengaja: satu CAPTCHA yang gagal tidak boleh menjatuhkan sembilan lainnya.

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

Pola paralel ini cocok dengan penagihan CaptchaAI yang berbasis thread: jumlah task serempak dibatasi oleh thread pada paket Anda, bukan oleh biaya per solve. Untuk beban ringan, BASIC ($15/bulan, 5 thread) menampung lima penyelesaian sekaligus; naikkan ke STANDARD ($30/bulan, 15 thread) atau ADVANCE ($90/bulan, 50 thread) saat concurrency bertambah.

Tabel pemecahan masalah cepat

Gejala Kemungkinan penyebab Perbaikan
Semua retry gagal seketika Error fatal ikut di-retry Periksa daftar klasifikasi error
Circuit breaker terus terbuka API bermasalah atau kunci salah Cek status layanan dan API key
Token sudah kedaluwarsa saat dikirim Waktu penyelesaian + jeda terlalu lama Selesaikan tepat sebelum navigasi ke form
AbortError saat fetch Batas waktu terlalu pendek Naikkan nilai AbortSignal.timeout
Unhandled promise rejection catch pada fungsi async terlewat Selalu tangani setiap rejection

Pertanyaan yang sering diajukan

Apakah exponential backoff wajib, atau cukup jeda tetap?

Untuk beban kecil, jeda tetap cukup. Tapi begitu banyak worker mencoba ulang bersamaan, jeda tetap membuat semuanya menabrak API pada detik yang sama. Exponential backoff plus jitter menyebar retry dan mencegah lonjakan beban saat pemulihan.

Kapan sebaiknya circuit breaker terbuka?

Saat kegagalan bersifat sistemik, bukan sesekali. Ambang lima kegagalan beruntun dengan jeda pemulihan 60 detik adalah titik awal yang wajar. Kalau layanan target Anda cenderung "berkedip" sesaat, naikkan ambangnya agar sirkuit tidak terlalu sensitif.

Berapa lama token CaptchaAI berlaku sebelum kedaluwarsa?

Bergantung tipenya: token reCAPTCHA umumnya sekitar dua menit dan Turnstile sekitar lima menit. Selesaikan CAPTCHA sedekat mungkin dengan momen pengiriman form, dan pasang TTL cache lebih pendek dari masa berlaku sebenarnya untuk memberi ruang latensi.

Apakah ERROR_NO_SLOT_AVAILABLE berarti paket saya kekecilan?

Belum tentu. Error itu muncul saat semua thread pada paket Anda sedang memproses CAPTCHA lain — sementara, dan biasanya hilang setelah jeda singkat. Kalau muncul terus di jam sibuk, itu sinyal concurrency Anda melebihi jumlah thread, dan menaikkan paket lebih tepat ketimbang memperbanyak retry.

Ringkasan

Penyelesaian CAPTCHA Node.js yang tahan produksi dengan CaptchaAI bertumpu pada lima lapisan: pilah error retriable vs fatal, terapkan exponential backoff dengan jitter, lindungi dengan circuit breaker, cache token untuk mencegah penyelesaian ganda, lalu ukur semuanya lewat metrik. Gabungkan kelimanya dan solver Anda akan bertahan menghadapi timeout, token basi, maupun gangguan sesaat pada layanan.

Artikel terkait

Langkah selanjutnya

Komentar dinonaktifkan untuk artikel ini.