Tutorials

Token bucket : limiter le débit des appels d'API CAPTCHA

Deux réglages suffisent pour maîtriser un pipeline de résolution : une capacité, la rafale que vous tolérez, et un taux de recharge, le débit soutenu en requêtes par seconde. C'est tout ce qu'est un token bucket, et c'est le premier correctif à appliquer quand ERROR_TOO_MUCH_REQUESTS apparaît dans vos logs. Un ThreadPoolExecutor à 30 workers borne le nombre de tâches en vol, jamais la vitesse à laquelle elles frappent l'endpoint.

Deux réglages, rien de plus

Réglage Ce qu'il contrôle Comment le fixer
Capacité La rafale maximale absorbée d'un coup 2 × le taux de recharge
Taux de recharge Le débit soutenu, en requêtes par seconde Ce que l'API accepte sans erreur
Bucket vide La requête attend Rien n'est rejeté, tout est étalé

Ce que fait le bucket, seconde par seconde

[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

Token bucket, leaky bucket ou fenêtre glissante ?

Algorithme Comportement Cas d'usage typique
Token bucket Débit lissé, rafales tolérées Appels d'API CAPTCHA
Leaky bucket Débit de sortie fixe, aucune rafale Quotas stricts
Fenêtre fixe Comptage par fenêtre, pics en bordure Compteurs simples
Fenêtre glissante Comptage sur période glissante Application fine du quota

Pour un scraper, le token bucket s'impose : votre crawler découvre vingt CAPTCHA d'un coup.

Un token bucket thread-safe en Python

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

Le verrou protège tokens et last_refill ; la recharge se calcule à la demande, sans thread de fond ni dérive d'horloge.

Brancher le limiteur sur l'API CaptchaAI

Un seul acquire() avant la soumission suffit. L'interrogation des résultats reste hors du limiteur : elle est légère et déjà espacée de cinq secondes.

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

La même logique en JavaScript

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

La version asynchrone calcule le temps d'attente exact au lieu de boucler : l'event loop de Node.js reste libre.

Traiter un lot de cent tâches

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

Calibrer la capacité et le taux de recharge

Charge de travail Capacité (rafale) Taux de recharge (soutenu)
Scraping léger 5 2/sec
Automatisation standard 20 10/sec
Pipeline à fort volume 50 30/sec
Débit maximal 100 50/sec

Règles empiriques

  • Capacité = 2 × le taux de recharge : deux secondes de rafale absorbées.
  • Démarrez bas, montez par paliers en surveillant le taux d'erreur.
  • Limitez les soumissions uniquement, jamais l'interrogation des résultats.

Aligner le débit sur vos threads

La facturation CaptchaAI se fait au thread simultané, pas à la résolution. Avec BASIC ($15/mois, 5 threads), jamais plus de cinq résolutions en vol : un taux de recharge de 2/sec suffit. Sur ADVANCE ($90/mois, 50 threads), visez la ligne « automatisation standard » ci-dessus.

Le piège du déploiement distribué

Dès que les workers tournent sur une flotte Scaleway ou OVHcloud, ou sur trois instances en eu-west-3 (Paris), chaque processus applique son propre bucket en mémoire : le débit réel est multiplié d'autant. Divisez le taux cible par le nombre de processus, ou déportez les compteurs dans Redis.

Dépannage

Problème Cause Correctif
Les requêtes sont encore limitées Débit supérieur à ce que l'API accepte Baisser le taux de recharge
Latence anormale à la soumission Bucket vide, attente de recharge Augmenter la capacité pour absorber les rafales
Mémoire qui grimpe La file d'attente s'accumule sans borne Fixer une taille maximale et refuser le surplus
Limiteur non partagé entre processus Compteurs en mémoire locale Passer sur un token bucket adossé à Redis

FAQ

Le token bucket remplace-t-il un pool de threads ?

Non, les deux couches sont complémentaires : le pool borne les tâches simultanées, le bucket la vitesse d'entrée. Sans lui, trente workers envoient trente soumissions dans la même milliseconde.

Quelle capacité choisir selon mon plan ?

Partez du nombre de threads de votre plan : les résolutions en vol sont déjà plafonnées par ce quota. Le bucket sert à lisser les pics de soumission qui déclenchent ERROR_TOO_MUCH_REQUESTS.

Comment partager un limiteur entre plusieurs machines ?

Déplacez l'état dans Redis et incrémentez les compteurs via un script Lua : recharge et consommation restent atomiques. Un bucket par clé API suffit.

Prochaines étapes

Passez d'un débit subi à un débit choisi : récupérez votre clé API CaptchaAI et instrumentez vos soumissions dès le premier lot.

Les commentaires sont désactivés pour cet article.