Tutorials

Rate-Limited Concurrency: Token Bucket untuk CAPTCHA API Call

Untuk sebagian besar pipeline scraping, jawabannya hanya dua angka: kapasitas 20 dan isi ulang 10 token per detik, dipasang tepat di depan panggilan in.php. Itulah token bucket — satu objek kecil yang menahan request ketika jatah habis, lalu melepasnya lagi begitu token terisi, sehingga laju rata-rata tetap terkendali tanpa mematikan kemampuan burst.

Pisahkan dua hal sejak awal: bucket mengatur seberapa cepat request masuk, sedangkan thread pada paket CaptchaAI mengatur berapa CAPTCHA berjalan bersamaan. Sisanya berisi implementasi Python dan Node.js serta cara memilih kedua angka tadi.

Skenario dua region dengan satu API key

Bayangkan tim price monitoring di Jakarta: worker utama di AWS ap-southeast-3 (Jakarta), batch malam di ap-southeast-1 (Singapura), satu API key untuk keduanya. Pukul 02.00 kedua jadwal crawl berangkat bersamaan:

  • Tiap region mengirim sekitar 18 submit per detik — wajar sendiri-sendiri, tetapi agregatnya 36 per detik dan ERROR_TOO_MUCH_REQUESTS mulai berdatangan.
  • Menambah worker tidak menolong; tugas hanya menumpuk lebih cepat di depan endpoint yang sama.
  • Dengan token bucket 10 per detik di tiap region, agregat turun ke sekitar 20 per detik dan grafik error kembali datar.

Dua catatan: ukur waktu tunggu bucket terpisah dari waktu penyelesaian CAPTCHA, dan ambil hanya data yang boleh Anda proses — UU Pelindungan Data Pribadi (UU 27/2022) membuat batasan itu bukan sekadar praktik baik.

Mengapa token bucket, bukan leaky bucket atau fixed window

CAPTCHA tidak datang merata: satu halaman kategori bisa memunculkan 20 tantangan sekaligus, lalu sepi semenit. Algoritma yang memaksa laju keluaran tetap justru memperlambat pekerjaan yang masih di bawah rata-rata.

Algoritma Perilaku Cocok untuk
Token bucket Laju halus dengan jatah burst tersimpan Panggilan API CAPTCHA yang datang bergerombol
Leaky bucket Laju keluaran tetap, burst diratakan habis Kebutuhan laju yang sangat ketat
Fixed window Hitungan per jendela waktu, lonjakan di tepi jendela Penghitung sederhana
Sliding window Hitungan pada periode bergulir Penegakan laju yang akurat

Anatomi bucket: kapasitas, refill rate, dan antrean tunggu

[Bucket] capacity=20, refill=10/sec

Time 0:  ████████████████████  20 tokens available
         → 15 requests consume 15 tokens
Time 0:  █████                 5 tokens remain

Time 1s: ███████████████       15 tokens (5 + 10 refilled)
         → 15 requests consume 15 tokens
Time 1s: (empty)               0 tokens

Time 2s: ██████████            10 tokens (0 + 10 refilled)
         → Request waits if bucket is empty

Tiga properti menentukan perilakunya:

  • Kapasitas — token maksimum yang boleh menumpuk, alias ukuran burst yang lewat sekaligus.
  • Refill rate — laju berkelanjutan per detik; angka inilah yang menentukan throughput jangka panjang.
  • Perilaku saat kosong — permintaan menunggu, bukan ditolak.

Properti ketiga membuat pola ini pas untuk solver CAPTCHA: menahan request 300 milidetik jauh lebih murah daripada membuang tugas dan mengulang alur scraping.

Bucket Python yang aman untuk thread

Versi berikut memakai time.monotonic() agar perubahan jam sistem tidak merusak perhitungan, plus Lock supaya worker ThreadPoolExecutor tidak menghitung isi ulang bersamaan.

import time
import threading


class TokenBucket:
    def __init__(self, capacity, refill_rate):
        """
        Args:
            capacity: Maximum tokens (burst size)
            refill_rate: Tokens added per second
        """
        self.capacity = capacity
        self.refill_rate = refill_rate
        self.tokens = capacity
        self.last_refill = time.monotonic()
        self.lock = threading.Lock()

    def acquire(self, timeout=None):
        """Block until a token is available."""
        deadline = time.monotonic() + timeout if timeout else float("inf")

        while True:
            with self.lock:
                self._refill()
                if self.tokens >= 1:
                    self.tokens -= 1
                    return True

            # Check timeout
            if time.monotonic() >= deadline:
                return False

            # Wait before retrying (avoid busy loop)
            time.sleep(min(1.0 / self.refill_rate, 0.1))

    def _refill(self):
        now = time.monotonic()
        elapsed = now - self.last_refill
        new_tokens = elapsed * self.refill_rate
        self.tokens = min(self.capacity, self.tokens + new_tokens)
        self.last_refill = now

Tiga detail yang layak dicatat:

  • Isi ulang dihitung proporsional terhadap waktu berlalu — tidak ada thread latar yang perlu dijaga.
  • Parameter timeout mencegah worker menggantung kalau bucket disetel terlalu ketat.
  • Jeda min(1.0 / self.refill_rate, 0.1) menahan busy loop.

Menempelkan bucket di depan submit CaptchaAI

Urutannya: ambil token, kirim ke in.php, lalu biarkan polling apa adanya — res.php sudah menahan dirinya sendiri lewat time.sleep(5).

import os
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed

API_KEY = os.environ["CAPTCHAAI_API_KEY"]

# Allow 10 submissions/sec with burst of 20
rate_limiter = TokenBucket(capacity=20, refill_rate=10)


def solve_captcha_rate_limited(sitekey, pageurl):
    """Solve with rate limiting on submission."""
    # Wait for token before submitting
    rate_limiter.acquire()

    resp = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": sitekey,
        "pageurl": pageurl,
        "json": 1
    })
    data = resp.json()

    if data.get("status") != 1:
        raise RuntimeError(data.get("request"))

    captcha_id = data["request"]

    # Polling doesn't need rate limiting (separate concern)
    for _ in range(60):
        time.sleep(5)
        result = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY, "action": "get", "id": captcha_id, "json": 1
        }).json()

        if result.get("status") == 1:
            return result["request"]
        if result.get("request") != "CAPCHA_NOT_READY":
            raise RuntimeError(result.get("request"))

    raise TimeoutError("Solve timeout")


# Run 100 tasks through rate limiter
tasks = [
    {"sitekey": "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
     "pageurl": f"https://example.com/p/{i}"}
    for i in range(100)
]

with ThreadPoolExecutor(max_workers=30) as executor:
    futures = {
        executor.submit(
            solve_captcha_rate_limited, t["sitekey"], t["pageurl"]
        ): t for t in tasks
    }

    for future in as_completed(futures):
        task = futures[future]
        try:
            solution = future.result()
            print(f"[OK] {task['pageurl']}")
        except Exception as e:
            print(f"[ERR] {task['pageurl']}: {e}")

max_workers=30 bukan pengatur laju: angka itu menentukan berapa tugas berada di fungsi solve bersamaan, sedangkan kecepatan request ke in.php ditentukan bucket.

Versi Node.js: bucket async tanpa lock

Event loop Node.js single-threaded, jadi tidak perlu lock — cukup hitung berapa lama harus menunggu sebelum await berikutnya lanjut.

class TokenBucket {
  constructor(capacity, refillRate) {
    this.capacity = capacity;
    this.refillRate = refillRate; // tokens per second
    this.tokens = capacity;
    this.lastRefill = Date.now();
    this.waitQueue = [];
  }

  _refill() {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
    this.lastRefill = now;
  }

  async acquire() {
    this._refill();

    if (this.tokens >= 1) {
      this.tokens -= 1;
      return;
    }

    // Wait until a token is available
    const waitTime = ((1 - this.tokens) / this.refillRate) * 1000;
    await new Promise((resolve) => setTimeout(resolve, waitTime));

    this._refill();
    this.tokens -= 1;
  }
}

Batch solver Node.js dengan pembatas laju

Promise.allSettled memberangkatkan semua tugas sekaligus; bucket menahan tiap panggilan di 10 submit per detik.

const axios = require("axios");

const API_KEY = process.env.CAPTCHAAI_API_KEY;
const rateLimiter = new TokenBucket(20, 10); // 20 burst, 10/sec sustained

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function solveCaptchaLimited(sitekey, pageurl) {
  // Wait for rate limit token
  await rateLimiter.acquire();

  const submitResp = await axios.post(
    "https://ocr.captchaai.com/in.php",
    null,
    {
      params: {
        key: API_KEY,
        method: "userrecaptcha",
        googlekey: sitekey,
        pageurl: pageurl,
        json: 1,
      },
    }
  );

  if (submitResp.data.status !== 1) {
    throw new Error(submitResp.data.request);
  }

  const captchaId = submitResp.data.request;

  for (let i = 0; i < 60; i++) {
    await sleep(5000);
    const result = await axios.get("https://ocr.captchaai.com/res.php", {
      params: { key: API_KEY, action: "get", id: captchaId, json: 1 },
    });

    if (result.data.status === 1) return result.data.request;
    if (result.data.request !== "CAPCHA_NOT_READY") {
      throw new Error(result.data.request);
    }
  }

  throw new Error("TIMEOUT");
}

// Solve 100 tasks — rate limiter ensures max 10 submissions/sec
async function batchSolve(tasks) {
  const results = await Promise.allSettled(
    tasks.map((t) => solveCaptchaLimited(t.sitekey, t.pageurl))
  );

  const solved = results.filter((r) => r.status === "fulfilled").length;
  const failed = results.filter((r) => r.status === "rejected").length;
  console.log(`Solved: ${solved}, Failed: ${failed}`);
}

Pola ini juga rapi untuk fungsi serverless — lihat catatannya di FAQ.

Memilih kapasitas dan refill rate untuk beban Anda

Jenis beban kerja Kapasitas (burst) Refill rate (berkelanjutan)
Scraping ringan 5 2/detik
Otomatisasi standar 20 10/detik
Pipeline volume tinggi 50 30/detik
Throughput maksimum 100 50/detik

Aturan praktis saat menyetelnya:

  • Setel kapasitas sekitar 2x refill rate, sehingga burst dua detik masih tertampung.
  • Mulai konservatif, naikkan bertahap sambil memantau kode error.
  • Batasi hanya submit; polling ringan dan sudah menahan dirinya sendiri.
  • Simpan kedua angka di environment variable agar bisa diubah tanpa deploy.

Angka di tabel adalah titik awal, bukan batas resmi — naikkan setelah grafik error datar beberapa jam pada beban penuh.

Laju submit vs jumlah thread: dua batas yang sering tertukar

Banyak pipeline menganggap keduanya satu hal, lalu bingung kenapa menambah worker tidak menaikkan throughput.

Dimensi Dikendalikan oleh Gejala saat salah setel
Laju submit (request per detik) Token bucket di kode Anda ERROR_TOO_MUCH_REQUESTS, saldo terbuang untuk request ditolak
Konkurensi (CAPTCHA in-flight) Jumlah thread pada paket CaptchaAI Tugas mengantre; latensi naik tanpa kode error

CaptchaAI menagih per thread, bukan per solve, dengan solve tanpa batas di tiap thread. Jadi BASIC ($15/bulan, 5 thread) dan ADVANCE ($90/bulan, 50 thread) menentukan berapa CAPTCHA berjalan bersamaan — bukan seberapa cepat Anda boleh menekan endpoint.

Bagi kontraktor scraping lepas yang membayar paketnya sendiri, pembedaan ini terasa di anggaran: menaikkan paket tidak menghilangkan ERROR_TOO_MUCH_REQUESTS yang penyebabnya laju submit.

Gejala umum dan cara memperbaikinya

Gejala Penyebab Perbaikan
Request masih kena batas Pembatas laju lebih tinggi dari yang diterima akun Turunkan refill rate bertahap
Latensi per request melonjak Token habis, worker menunggu isi ulang Naikkan kapasitas
Pemakaian memori naik terus Antrean tunggu menumpuk tanpa batas Batasi ukuran antrean, tolak kelebihannya
Batas tidak konsisten antar-proses Bucket hidup di memori satu proses Pindahkan hitungan token ke Redis
Throughput mentok padahal error nol Anda menabrak batas thread, bukan batas laju Tambah thread paket, bukan refill rate

Pertanyaan yang sering muncul

Apakah token bucket menggantikan batas thread di paket CaptchaAI?

Tidak. Bucket membatasi request per detik; thread membatasi berapa CAPTCHA diproses bersamaan. Kalau kode error laju bersih tetapi throughput tetap datar, yang kurang adalah thread — misalnya pindah dari STANDARD ($30/bulan, 15 thread) ke ADVANCE ($90/bulan, 50 thread).

Latensi naik tetapi error nol — kapasitas atau refill rate yang harus dinaikkan?

Naikkan kapasitas lebih dulu. Latensi naik tanpa kode error biasanya berarti gerombolan request sedang menunggu token, bukan laju berkelanjutan yang terlalu tinggi. Kalau waktu tunggu tetap panjang, barulah refill rate yang kurang.

Bagaimana membagi satu pembatas laju antar-container atau antar-region?

Pindahkan penghitung token ke Redis dan ambil token lewat skrip Lua supaya atomik. Alternatif sederhana: bagi jatah statis — empat container masing-masing 5 per detik untuk agregat 20 per detik, seperti kasus Jakarta-Singapura di atas.

Bagaimana menerapkannya di fungsi serverless yang instance-nya datang dan pergi?

Bucket in-memory tetap berguna, tetapi hitung laju agregatnya: refill rate per instance dikali jumlah instance yang hidup bersamaan. Kalau platform menaikkan concurrency otomatis, kunci batas atasnya atau pindahkan bucket ke Redis.

Artikel terkait

Langkah berikutnya

Bangun alur penyelesaian CAPTCHA dengan laju yang Anda kendalikan — ambil API key CaptchaAI, pasang token bucket di depan submit, lalu naikkan angkanya perlahan sambil memantau kode error.

Panduan terkait:

Komentar dinonaktifkan untuk artikel ini.