Параллельное выполнение request

Что вы узнаете

Как выполнять большой объем параллельных запросов FourA без ошибок 429, ограничивая число активных вызовов и корректно обрабатывая отказы вместо их повторения.

Предварительные требования

Лимиты, с которыми вы работаете

Ваш тарифный план устанавливает два ограничения для каждого endpoint: количество одновременно выполняемых запросов и количество запросов, запускаемых в минуту (для Browser действует лимит в день вместо поминутного). У Single, Proxy и Browser свои параметры, которые указаны на вкладке Limits & Features в разделе Usage & Limits.

Запрос, превышающий лимит параллельных подключений, возвращает HTTP 429 с кодом X-FourA-Limit: plan_limit_concurrency и значением лимита в теле ответа:

{
  "error": "Concurrency limit reached: your plan allows 50 simultaneous single request(s). Retry when an in-flight request finishes.",
  "reason": "plan_limit_concurrency",
  "documentation": "https://foura.ai/prices",
  "limit": 50,
  "in_flight": 51,
  "retry_after_seconds": 1
}

Запрос, превышающий лимит в минуту, возвращает X-FourA-Limit: plan_limit_rate со значением retry_after_seconds до конца текущей минуты.

Оба отказа происходят мгновенно. FourA не ставит вызов в очередь для последующей обработки, поэтому ресурсы не тратятся и плата не списывается. Однако отклоненные вызовы учитываются в скользящем минутном окне, поэтому шквал повторных попыток (retry storm) только продлевает период ожидания. Полный справочник по полям: Rate Limits.

Два важных момента, о которых часто забывают:

  • Сам вызов POST /api/auto/ не учитывается отдельно, но учитывается каждый вложенный вызов Single, Proxy и Browser, который он выполняет. Один вызов auto может занимать более одного слота во время работы цепочки попыток.
  • Browser request занимает слот на все время рендеринга страницы, что значительно дольше, чем Single request. Пакет вызовов browser достигает лимита при меньшем количестве запросов, чем пакет вызовов single.

Шаг 1: Ограничьте собственный уровень параллелизма

Выберите значение ниже установленного лимита для вызываемого endpoint и придерживайтесь его. Пул воркеров решает эту задачу в одну строку:

import os
import concurrent.futures
import requests

API = "https://eu.api.foura.ai/api/single/"
KEY = os.environ["FOURA_API_KEY"]
HEADERS = {"X-API-Key": KEY, "Content-Type": "application/json"}

# Below your plan's Single concurrency, so a slow request never pushes the batch over it.
MAX_IN_FLIGHT = 30

def fetch_one(url):
    resp = requests.post(API, headers=HEADERS, json={"method": "GET", "url": url}, timeout=60)
    return url, resp.status_code, resp.json()

def fetch_all(urls):
    results = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_IN_FLIGHT) as pool:
        for outcome in concurrent.futures.as_completed(pool.submit(fetch_one, u) for u in urls):
            results.append(outcome.result())
    return results

max_workers представляет собой весь механизм. В пуле никогда не бывает открыто больше этого числа вызовов, поэтому пакет может содержать миллион URL и при этом не превышать лимит.

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

Step 2: Back Off Instead of Re-Sending

Если отказ все же получен, неправильным решением будет немедленно отправить весь пакет повторно. Каждый вызов в нем снова получит отказ, а повторные попытки будут накладываться на запросы, которые уже выполнялись.

Сначала подождите. Время ожидания указано в header Retry-After и в retry_after_seconds в body:

import time

# Plan limits that no short wait will clear.
STOP_ON = {"plan_limit_browser_daily", "plan_limit_credits", "plan_limit_bandwidth", "plan_limit_feature", "plan_limit_premium"}

def fetch_one(url, attempts=4):
    for attempt in range(attempts):
        resp = requests.post(API, headers=HEADERS, json={"method": "GET", "url": url}, timeout=60)

        limit = resp.headers.get("X-FourA-Limit")
        if limit in STOP_ON:
            raise RuntimeError(f"stopped by {limit}")

        if resp.status_code not in (429, 503):
            return url, resp.status_code, resp.json()

        header = resp.headers.get("Retry-After")
        if header and header.isdigit():
            wait = int(header)
        else:
            body = resp.json()
            wait = body.get("retry_after_seconds") or body.get("retryAfter") or 2 ** attempt
        time.sleep(wait)

    return url, 429, {"error": "still refused after retries"}

Добавляйте джиттер при запуске большого количества воркеров. Без него каждый воркер, получивший отказ в одну и ту же секунду, проснется в ту же секунду и снова получит отказ вместе с остальными.

import random
time.sleep(wait + random.uniform(0, 0.5))

Шаг 3: То же самое на Node

const API = 'https://eu.api.foura.ai/api/single/';
const HEADERS = {
  'X-API-Key': process.env.FOURA_API_KEY,
  'Content-Type': 'application/json',
};
const MAX_IN_FLIGHT = 30;

async function fetchOne(url) {
  const resp = await fetch(API, {
    method: 'POST',
    headers: HEADERS,
    body: JSON.stringify({ method: 'GET', url }),
  });
  return { url, status: resp.status, body: await resp.json() };
}

async function fetchAll(urls) {
  const queue = [...urls];
  const results = [];

  async function worker() {
    while (queue.length) {
      results.push(await fetchOne(queue.pop()));
    }
  }

  await Promise.all(
    Array.from({ length: Math.min(MAX_IN_FLIGHT, urls.length) }, worker)
  );
  return results;
}

Фиксированное количество воркеров, берущих задачи из одной очереди, держит открытым ровно столько вызовов, независимо от размера пакета.

Шаг 4: Отслеживайте фактическое использование

Откройте Usage & Limits в панели управления. Счетчики в реальном времени рядом с вашими лимитами отображают concurrency, минутный rate limit и browser requests в день по мере их изменения, что позволяет настроить MAX_IN_FLIGHT на основе реального прогона, а не догадок.

Activity Log также фиксирует отказы. Прогон, завершившийся с нужным количеством строк, но с разбросом результатов rate_limit, сам себя ограничивал по rate limit.

Распространенные ошибки

  • Запуск всего списка одновременно. asyncio.gather для 5 000 URL или Promise.all для неограниченного массива открывает 5 000 вызовов. Ограничивайте пул, а не список.
  • Параллельные повторные попытки. Немедленная повторная отправка каждого отклоненного вызова повторяет всплеск, вызвавший отказ. Подождите Retry-After и добавьте джиттер.
  • Ожидание сброса дневного или расчетного лимита. plan_limit_browser_daily сбрасывается в полночь по UTC, plan_limit_credits и plan_limit_bandwidth в конце расчетного периода. Остановите выполнение и прочитайте resets_at из тела ответа, если оно присутствует.
  • Учет auto вызовов как одного слота. Внутренняя цепочка /api/auto/ выполняет реальные подзапросы, и именно они учитываются в лимитах.
  • Использование одного ключа в нескольких процессах без разделения бюджета. Лимиты действуют на весь аккаунт, а не на отдельный процесс.
  • Настройка единого пула для всех endpoint. Для Single, Proxy и Browser действуют собственные значения concurrency. Пул, настроенный для Single, превысит более низкий лимит Browser.

Связанные материалы

  • Rate Limits: Все типы отказов и значения каждого поля
  • Response Headers: X-FourA-Limit и Retry-After
  • Request Outcomes: Почему статус rate_limit никогда не тарифицируется
  • Usage & Limits: Где находятся счетчики в реальном времени и лимиты вашего тарифа
  • Smart Fetch (Auto): Как один auto запрос разворачивается в несколько подзапросов
Обновлено: 10 сентября 2026 г.