Tutorials

Token bucket для API решения капчи: ограничение частоты запросов

Короткий ответ: поставьте корзину токенов (token bucket) перед вызовом in.php, задайте ей устойчивую частоту и запас на всплеск — и пул воркеров перестанет упираться в ERROR_TOO_MUCH_REQUESTS. Сам пул при этом можно не трогать: ограничивается не число потоков, а темп отправок.

Разницу часто путают. Тариф CaptchaAI считает потоки — сколько задач CAPTCHA одновременно в работе: BASIC ($15/мес, 5 потоков), ADVANCE ($90/мес, 50 потоков). Частота HTTP-запросов к in.php — отдельная величина, и ломается обычно она, когда ThreadPoolExecutor на тридцать воркеров просыпается разом.

Дальше — устройство алгоритма, реализации на Python и JavaScript, подбор параметров и разбор сбоев.

Как устроен алгоритм token bucket

[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

В корзине лежат токены, каждый запрос забирает один. Токены капают обратно с постоянной скоростью и сверх потолка не накапливаются. Отсюда два параметра:

  • Ёмкость (capacity) — потолок корзины, то есть максимальный всплеск. Если корзина полна, 20 отправок уйдут мгновенно, одна за другой.
  • Частота пополнения (refill rate) — устойчивый темп в запросах в секунду. Это то, что вы держите в среднем на длинной дистанции.

Третье свойство — поведение при пустой корзине: запрос ждёт, а не отклоняется. Для решения CAPTCHA это правильный выбор — задача не теряется, просто стартует на несколько сотен миллисекунд позже.

Реализация на Python: потокобезопасная корзина

Класс TokenBucket с блокировкой

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

Две детали, которые часто упускают в самописных лимитерах: пополнение считается по time.monotonic(), а не по time.time() — иначе синхронизация по NTP собьёт расчёт; пауза в цикле ограничена сверху 0,1 с, чтобы ожидание не стало busy loop. Параметр timeout возвращает False, если токен так и не появился, — удобно, когда у задачи есть свой дедлайн.

Подключение корзины к отправке задачи в in.php

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

Важен порядок: acquire() вызывается до requests.post, то есть ограничивается отправка. Цикл опроса res.php через токен не проходит — он уже саморегулируется паузой time.sleep(5), и лишние токены на него тратить незачем.

ThreadPoolExecutor(max_workers=30) и корзина работают на разных уровнях: пул задаёт, сколько задач висит одновременно (это потоки тарифа), корзина — с какой скоростью они стартуют.

Реализация на 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;
  }
}

В Node.js блокировать нечего: событийный цикл однопоточный, поэтому вместо мьютекса используется await на таймере — метод считает, сколько миллисекунд ждать до следующего токена, и засыпает ровно на это время.

Ограничение у этой версии известное: несколько десятков промисов могут проснуться почти синхронно и слегка превысить заданную частоту. Нужна жёсткая граница — добавьте очередь ожидания (поле waitQueue оставлено под это).

Пакетная отправка ста задач

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

Как подобрать ёмкость и частоту пополнения

Начинать стоит снизу. Значения ниже — отправные точки: реальный потолок зависит от тарифа и от того, сколько процессов делят один API-ключ.

Профиль нагрузки Ёмкость (всплеск) Частота пополнения (устойчивая)
Лёгкий парсинг 5 2/сек
Обычная автоматизация 20 10/сек
Высоконагруженный пайплайн 50 30/сек
Максимальная пропускная способность 100 50/сек

Три практических правила:

  • Ёмкость ≈ удвоенная частота пополнения. Так корзина переваривает двухсекундный всплеск целиком — типичный случай, когда парсер разом наткнулся на пачку страниц с проверкой.
  • Повышайте частоту постепенно, наблюдая за долей ошибок. Прыжок с 10/сек сразу на 50/сек не даст вам понять, где именно проходит граница.
  • Ограничивайте только отправки. Опрос res.php лёгкий и уже растянут паузами; лишнее ограничение на нём только увеличит время решения.

Пример: агентство на 12 клиентских проектов

Команда в Алматы или Минске держит 12 клиентских парсинг-проектов на одном сервере: тариф ADVANCE ($90/мес, 50 потоков), у каждого проекта свой шедулер, ключ API один на всех. Ночью крон-задачи стартуют по круглому часу — отправки складываются в один пик.

Тариф поднимать незачем. Достаточно вынести корзину на уровень шлюза: один общий лимитер 20/сек с ёмкостью 40 вместо двенадцати независимых. Потоков это не касается, но пик сглаживается и ночные ERROR_TOO_MUCH_REQUESTS уходят.

Когда процессы разнесены по машинам, in-memory корзина перестаёт работать: каждый экземпляр считает свои токены, а API видит сумму. Тогда tokens и last_refill переезжают в общий ключ Redis.

Token bucket, leaky bucket и оконные счётчики: что выбрать

Алгоритм Поведение Когда уместен
Token bucket Плавная средняя частота с допуском на всплеск Отправки задач в API CAPTCHA
Leaky bucket Фиксированная скорость на выходе, всплески срезаются Жёсткие требования к равномерности
Фиксированное окно Счётчик за интервал, всплески на стыке окон Простые квоты и счётчики
Скользящее окно Счётчик за скользящий период Точное соблюдение квоты

Token bucket выигрывает по одной причине: нагрузка на решение CAPTCHA неравномерна. Парсер то не встречает проверок вовсе, то натыкается на двадцать подряд. Leaky bucket растянет их в очередь и добавит лишнюю задержку, фиксированное окно пропустит двойной всплеск на стыке интервалов. Корзина сочетает оба свойства: держит частоту и переваривает всплеск.

Что делать, если ошибки не ушли

Симптом Причина Что сделать
ERROR_TOO_MUCH_REQUESTS при работающем лимитере Частота выставлена выше, чем принимает API Снизьте refill_rate и проверьте, не делят ли ключ несколько процессов
Отправки стали заметно медленнее Токены исчерпаны, задачи ждут пополнения Увеличьте ёмкость — всплеск не помещается в корзину
Память процесса растёт Копится очередь ожидающих запросов Ограничьте длину очереди и отклоняйте лишнее с понятной ошибкой
Лимит не соблюдается при нескольких процессах Корзина живёт в памяти одного процесса Вынесите состояние в Redis и обновляйте его атомарно
Частота «плавает» после долгого простоя Часы сдвинулись между вызовами Считайте пополнение по монотонным часам, а не по системному времени

Логируйте не только ошибки, но и время ожидания токена. Если среднее ожидание стабильно растёт, входящий поток задач превышает вашу устойчивую частоту — настройкой корзины это не лечится.

FAQ

Ограничивать только отправки или ещё и опрос res.php?

Только отправки. Запросы к res.php лёгкие и уже разнесены паузой в 5 секунд. Пропуская их через ту же корзину, вы отнимете токены у новых задач и увеличите время решения, ничего не выиграв.

Чем ограничение частоты отличается от числа потоков в тарифе?

Это разные величины. Потоки — сколько задач CAPTCHA одновременно в обработке; их число задаёт тариф (BASIC $15/мес — 5 потоков, ADVANCE $90/мес — 50). Частота — сколько HTTP-вызовов в секунду уходит к in.php. Упереться в лимит частоты, не выбрав и половины потоков, — обычное дело.

Нужна ли отдельная корзина под каждый тип CAPTCHA?

Если типы конкурируют за пропускную способность — да: отдельные корзины для reCAPTCHA v2 и Cloudflare Turnstile не дадут большому потоку одного типа задавить другой. При сопоставимых объёмах хватит одной общей — её проще сопровождать.

Как ограничить частоту, когда воркеры разнесены по нескольким серверам?

Перенесите состояние в общее хранилище, обычно Redis: tokens и last_refill в одном ключе, пересчёт атомарный (Lua-скрипт или WATCH/MULTI). Иначе два воркера прочитают одно и то же число токенов и оба его потратят. Логика не меняется — меняется место хранения.

Что выбрать вместо ожидания — отклонять запросы?

Для парсинга и QA-прогонов ожидание почти всегда лучше: задача не теряется. Отклонение уместно там, где у вызывающей стороны есть альтернатива — например, в интерактивном сервисе. Ставьте на acquire() разумный timeout и обрабатывайте отказ явно.


Куда двигаться дальше

Комментарии для этой статьи отключены.