# Run Requests in Parallel

> URL: https://foura.ai/docs/how-to/run-requests-in-parallel
> Updated: 2026-10-15
> Index: https://foura.ai/llms.txt

## 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](https://foura.ai/docs/getting-started/authentication))
- 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](https://foura.ai/docs/dashboard/quota) 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:

```json
{
  "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` says how long until one more request would get in (1 to 120 seconds).

Both refusals are immediate. FourA doesn't queue the call and hand it back later, so nothing is spent and nothing is billed. A refused call doesn't count toward the sliding minute, so an account asking a little over its ceiling still gets the full ceiling. A retry storm gets its own answer: when refused calls keep arriving at more than twice your per-minute ceiling, every call on that endpoint is refused with `cooldown: true` and `retry_after_seconds: 30`, so pause for those 30 seconds. Full field reference: [Rate Limits](https://foura.ai/docs/api/rate-limits).

Two things count that people often miss:

- A `POST /api/auto/` call holds no slot by itself, but every Single, Proxy and Browser sub-call it makes for you does. One auto call can hold more than one slot while its ladder runs. (On your dashboard the auto call is the request; its sub-calls are its attempts.)
- 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:

```python
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 use the same account, even with different API keys, 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:

```python
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.

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

## Step 3: The Same Thing in Node

```javascript
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](https://foura.ai/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](https://foura.ai/docs/dashboard/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.gather` over 5,000 URLs, or `Promise.all` over 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_daily` clears at midnight UTC, `plan_limit_credits` and `plan_limit_bandwidth` at the end of the billing period. Stop the run and read `resets_at` from 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](https://foura.ai/docs/api/rate-limits): Every refusal shape and what each field means
- [Response Headers](https://foura.ai/docs/api/response-headers): `X-FourA-Limit` and `Retry-After`
- [Request Outcomes](https://foura.ai/docs/api/outcomes): Why a `rate_limit` outcome is never billed
- [Usage & Limits](https://foura.ai/docs/dashboard/quota): Where the live counters and your plan's numbers are
- [Smart Fetch (Auto)](https://foura.ai/docs/guides/smart-fetch): How one auto call turns into several sub-calls
