Run Requests in Parallel
What You'll Learn
How to run a large batch of FourA requests concurrently without collecting 429s, by capping how many calls you keep open and by reacting to a refusal instead of repeating it.
Prerequisites
- A FourA API key (get one here)
- Python 3.9+ with
requests, or Node.js 18+
The Limits You're Working Against
Your plan carries two ceilings per endpoint: how many requests may run at the same time, and how many may start per minute (Browser has a per-day allowance instead of a per-minute one). Single, Proxy and Browser each have their own numbers, and the Limits & Features tab of Usage & Limits lists them.
The request that goes over the concurrency ceiling comes back as HTTP 429 with X-FourA-Limit: plan_limit_concurrency and the ceiling in the body:
{
"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
}
The one that goes over the per-minute ceiling comes back with X-FourA-Limit: plan_limit_rate and retry_after_seconds running to the end of the minute.
Both refusals are immediate. FourA doesn't queue the call and hand it back later, so nothing is spent and nothing is billed. Refused calls do count toward the sliding minute, though, so a retry storm extends its own cooldown. Full field reference: Rate Limits.
Two things count that people often miss:
- A
POST /api/auto/call isn't counted by itself, but every Single, Proxy and Browser sub-call it makes for you is. One auto call can hold more than one slot while its ladder runs. - A Browser request occupies its slot for as long as the page takes to render, which is far longer than a Single request. A batch of browser calls fills its ceiling with fewer requests than a batch of single calls.
Step 1: Cap Your Own Concurrency
Pick a number below the ceiling for the endpoint you're calling and hold it. A worker pool does this in one line:
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 is the whole mechanism. The pool never has more than that many calls open, so the batch can be a million URLs long and still stay under the limit.
Leave headroom. If two processes on your side share one API key, they share one ceiling, so give each of them half. If a fast target lets the pool finish requests quicker than a minute allows, pace the workers to stay under the per-minute ceiling too.
Step 2: Back Off Instead of Re-Sending
If a refusal does arrive, the wrong answer is to re-send the batch at once. Every call in it gets refused again, and the retries pile on top of the requests that were already running.
Wait first. The wait is in the Retry-After header and in retry_after_seconds in the 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"}
Add jitter when you're running many workers. Without it, every worker refused in the same second wakes up in the same second and refuses together.
import random
time.sleep(wait + random.uniform(0, 0.5))
Step 3: The Same Thing in 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;
}
A fixed number of workers pulling from one queue keeps exactly that many calls open, whatever the batch size.
Step 4: Watch What You're Actually Using
Open Usage & Limits in the dashboard. The live counters next to your caps show concurrency, per-minute rate, and browser requests per day as they move, so you can size MAX_IN_FLIGHT against a real run rather than a guess.
The Activity Log records refusals too. A run that finished with the right number of rows but a scatter of rate_limit outcomes was throttling itself.
Common Mistakes
- Firing the whole list at once.
asyncio.gatherover 5,000 URLs, orPromise.allover an unbounded array, opens 5,000 calls. Bound the pool, not the list. - Retrying in parallel. Re-sending every refused call the moment it's refused reproduces the burst that caused the refusal. Wait the
Retry-After, and add jitter. - Treating a daily or period allowance as a wait.
plan_limit_browser_dailyclears at midnight UTC,plan_limit_creditsandplan_limit_bandwidthat the end of the billing period. Stop the run and readresets_atfrom the body where there is one. - Counting auto calls as one slot each. The ladder inside
/api/auto/makes real sub-calls, and those are what the ceilings count. - Sharing a key across processes without dividing the budget. The ceilings are per account, not per process.
- Sizing one pool for every endpoint. Single, Proxy and Browser each have their own concurrency number. A pool sized for Single overshoots a smaller Browser ceiling.
Related
- Rate Limits: Every refusal shape and what each field means
- Response Headers:
X-FourA-LimitandRetry-After - Request Outcomes: Why a
rate_limitoutcome is never billed - Usage & Limits: Where the live counters and your plan's numbers are
- Smart Fetch (Auto): How one auto call turns into several sub-calls