Tutorials

Ratenbegrenzte Parallelität: Token-Bucket für CAPTCHA-API-Aufrufe

Findet Ihr Scraper 100 CAPTCHAs auf einmal und schickt alle gleichzeitig an die API, antwortet CaptchaAI mit ERROR_TOO_MUCH_REQUESTS – Guthaben verbrannt, Durchsatz eingebrochen. Die saubere Lösung ist ein Token-Bucket: Er begrenzt Ihre Übermittlungen auf eine feste Rate wie „höchstens 20 pro Sekunde“ und lässt trotzdem kurze Bursts zu, solange freie Kapazität im Bucket liegt. Dieser Leitfaden zeigt eine Thread-sichere Implementierung in Python und eine asynchrone Variante in JavaScript – jeweils direkt vor der Übermittlung an die CaptchaAI-API eingehängt.

So funktioniert ein Token-Bucket

Ein Token-Bucket ist ein Behälter mit fester Kapazität, der sich kontinuierlich mit Tokens auffüllt. Jede Anfrage entnimmt ein Token; ist der Bucket leer, wartet die Anfrage, bis wieder aufgefüllt wurde. Kein Token geht verloren, keine Anfrage wird abgelehnt – der Verkehr wird lediglich geglättet.

[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

Die drei Stellschrauben:

  • Kapazität – die maximale Burst-Größe, also wie viele Anfragen kurzzeitig auf einmal durchgehen dürfen.
  • Nachfüllrate – die dauerhaft mögliche Anzahl Anfragen pro Sekunde.
  • Bei leerem Bucket warten Anfragen (keine Ablehnung, nur Drosselung).

Token-Bucket im Vergleich zu anderen Verfahren

Bevor Sie implementieren, lohnt der Blick auf die Alternativen. Vier Rate-Limiting-Verfahren sind gebräuchlich, und sie verhalten sich bei Bursts unterschiedlich.

Verfahren Verhalten Am besten geeignet für
Token-Bucket Gleichmäßige Rate mit Burst-Spielraum CAPTCHA-API-Aufrufe
Leaky Bucket Feste Ausgaberate, keine Bursts Strenge Ratenvorgaben
Fixed Window Zähler pro Zeitfenster, Bursts an den Rändern Einfache Zähler
Sliding Window Zählung über ein gleitendes Intervall Präzise Ratendurchsetzung

Für CAPTCHA-API-Aufrufe ist der Token-Bucket die beste Standardwahl: Er erlaubt natürliche Bursts – etwa wenn der Scraper 20 CAPTCHAs auf einmal findet – und erzwingt dennoch eine dauerhaft stabile Rate.

Python-Implementierung

Der Kern ist eine Klasse, die Tokens zeitbasiert nachfüllt und den Zugriff über ein Lock absichert, damit mehrere Threads sie gefahrlos teilen.

Thread-sicherer Token-Bucket

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

Ratenbegrenzter CAPTCHA-Löser

Der Limiter wird genau einmal instanziiert und von allen Worker-Threads geteilt. acquire() steht vor jeder Übermittlung – das Polling bleibt bewusst außen vor, weil es sich über sein Warteintervall selbst begrenzt.

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

JavaScript-Implementierung

In Node.js läuft alles in einer Event-Loop, ein Lock entfällt daher. Statt zu blockieren, wartet acquire() per await, bis genug Tokens nachgefüllt sind.

Asynchroner Token-Bucket

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

Ratenbegrenzter Stapellöser

Alle Aufgaben starten über Promise.allSettled scheinbar gleichzeitig – der geteilte rateLimiter sorgt dafür, dass die Übermittlungen dennoch mit höchstens 10 pro Sekunde durchsickern.

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

Kapazität und Nachfüllrate wählen

Die passenden Werte hängen vom Durchsatz Ihrer Pipeline ab. Diese Richtwerte sind ein guter Ausgangspunkt:

Richtwerte nach Arbeitslast

Arbeitslast Kapazität (Burst) Nachfüllrate (dauerhaft)
Leichtes Scraping 5 2/s
Standard-Automatisierung 20 10/s
Große Pipeline 50 30/s
Maximaler Durchsatz 100 50/s

Faustregeln für die Wahl

  • Setzen Sie die Kapazität auf das Doppelte der Nachfüllrate – das erlaubt Bursts von rund zwei Sekunden.
  • Beginnen Sie konservativ und erhöhen Sie die Rate schrittweise, während Sie die Fehlerquote beobachten.
  • Drosseln Sie nur die Übermittlungen – das Polling ist leichtgewichtig und begrenzt sich über sein Warteintervall von selbst.

Rate-Limiting über mehrere Worker verteilen

In der Praxis läuft eine Scraping-Pipeline selten auf einem einzigen Rechner. Verteilen Sie die Last etwa auf mehrere Hetzner- oder netcup-VPS, hält jeder Prozess seinen eigenen In-Memory-Bucket – und die Summe der lokalen Raten übersteigt schnell das, was ein einzelner API-Schlüssel verträgt. Zwei Ansätze helfen:

  • Rate pro Prozess aufteilen: Bei fünf Workern und einem Ziel von 10 Übermittlungen pro Sekunde bekommt jeder Worker eine Nachfüllrate von 2/s.
  • Gemeinsamer Bucket über Redis: Ein zentraler Token-Bucket in Redis erzwingt die Rate prozessübergreifend – der robustere Weg, sobald Worker dynamisch hoch- und herunterskalieren.

Hinweis: Orientieren Sie die Zielrate an Ihrem Thread-Kontingent. CaptchaAI rechnet pro gleichzeitigem Thread ab – nicht pro Lösung. Der Tarif ADVANCE (90 $/Monat, 50 Threads) erlaubt deutlich aggressivere Raten als BASIC (15 $/Monat, 5 Threads). Es bringt nichts, mehr Anfragen pro Sekunde zu übermitteln, als Ihre Threads gleichzeitig lösen können.

Fehlerbehebung

Problem Ursache Lösung
Anfragen werden trotz Limiter gedrosselt Rate höher gesetzt als der API-Schlüssel zulässt Nachfüllrate senken, bis sie zu den Limits von CaptchaAI passt
Hohe Latenz pro Anfrage Tokens erschöpft, Anfragen warten auf Nachfüllung Kapazität erhöhen, um Bursts abzufedern
Speicherverbrauch wächst Die Warteschlange der wartenden Anfragen läuft voll Maximale Warteschlangengröße setzen und Überschuss abweisen
Limiter greift nicht über Prozesse hinweg Bucket liegt nur im Arbeitsspeicher Redis-basierten Token-Bucket für verteiltes Rate-Limiting nutzen

Häufige Fragen

Sollte ich Übermittlungen, Polling oder beides drosseln?

Nur die Übermittlungen. Polling-Anfragen sind leichtgewichtig und drosseln sich über time.sleep(5) bereits selbst. Ein zusätzliches Limit auf dem Polling erhöht nur die Lösungszeit, ohne etwas zu bringen.

Wie hängt die Nachfüllrate mit meinem Thread-Kontingent zusammen?

Direkt. CaptchaAI rechnet pro gleichzeitigem Thread ab, und mehr Threads erlauben eine höhere dauerhafte Rate. Eine Übermittlungsrate, die dauerhaft über der Zahl gleichzeitig lösbarer Threads liegt, füllt nur die Warteschlange – sie beschleunigt nichts.

Funktioniert ein In-Memory-Bucket über mehrere Worker hinweg?

Nein. Jeder Prozess hält seinen eigenen Zähler, sodass sich die Gesamtrate mit der Zahl der Worker multipliziert. Teilen Sie die Zielrate auf die Prozesse auf oder verwenden Sie einen gemeinsamen Bucket in Redis.

Warum lehnt der Token-Bucket Anfragen nicht ab, sondern lässt sie warten?

Das ist der Kern des Prinzips: Bei leerem Bucket wartet die Anfrage auf das nächste Token, statt einen Fehler zu werfen. So bleibt der Durchsatz stabil, und Sie brauchen keine Retry-Logik für abgewiesene Anfragen.


Verwandte Leitfäden

Kommentare sind für diesen Artikel deaktiviert.