---
title: "Concurrent API Requests in Python: Asyncio Under Rate Limits"
description: "A Python asyncio fetcher that paces calls to your per-second limit, honours Retry-After and prices a 20,000-call batch at 20,000 credits."
url: https://perceptlayer.com/blog/concurrent-api-requests-python-rate-limit
---

[Blog](https://perceptlayer.com/blog) / [Data engineering](https://perceptlayer.com/blog/category/data-engineering)

# Concurrent API Requests in Python: Asyncio Under Rate Limits

19 Oct 2026 Perceptlayer 7 min read

To run async API requests in Python under a rate limit, bound two things separately: how many requests are in flight (an `asyncio.Semaphore`) and how often new ones start (a pacer). The semaphore alone doesn't enforce a rate. This tutorial builds both with `httpx`, reads the account's limit from response headers, and backs off on `429` using `Retry-After`.

## Key takeaways

-   A semaphore caps concurrency, not requests per second. Pair it with a pacer that spaces request starts at `60 / RateLimit-Limit` seconds.
-   Perceptlayer limits are per account, counted per minute, and shared by all keys: free accounts get 1 request a second, paying accounts at least 5.
-   A `429` or `503` carries `Retry-After` in seconds and costs nothing, so pause the whole pacer and retry. A client timeout may still have been billed, so don't retry it blindly.
-   Budget in credits first: 20,000 calls × 1 credit = 20,000 credits, and the account rate sets how long they take (4,000 seconds at 5 requests a second).

## Why doesn't a semaphore alone respect a rate limit?

A semaphore limits how many coroutines run a block at once, not how many start per second. If each call takes 200 milliseconds and you allow 10 in flight, you start about 50 requests a second, whatever the account allows. Concurrency and rate are different dials, and a rate limit only cares about the second one ( [`asyncio.Semaphore` docs](https://docs.python.org/3/library/asyncio-sync.html#asyncio.Semaphore)).

The relationship is simple: requests per second ≈ in-flight requests ÷ latency. Your latency isn't fixed, so a semaphore sized for today's latency overshoots the limit when the API gets quicker. The pacer makes the rate explicit. The semaphore stays as a safety valve, so a slow stretch can't pile up hundreds of open connections.

This matters most on one endpoint with a batch of queries, such as keyword lists against the [Google Search API](https://perceptlayer.com/apis/google-search). Every call is identical in cost, so the only thing that varies is how fast you're allowed to send them.

## What are the account's rate limits?

Limits are per account, shared by all its keys and counted per minute. Free accounts get 1 request a second (60 a minute). Paying accounts run at the rate of the credits they're using, and at least 5 a second (300 a minute). Starting a second process with a second key doesn't add capacity: both draw from the same minute.

| Account | Rate included |
| --- | --- |
| Free | 1 request a second (60 a minute) |
| Starter, Build, Growth, Pro | 5 requests a second |
| Scale | 10 requests a second |
| Volume | 20 requests a second |
| Volume 10M, Volume 25M | 25 requests a second |
| Volume 50M | 50 requests a second |

A faster rate can be chosen at checkout for a higher price, and credits bought at a faster rate are used first. The [pricing page](https://perceptlayer.com/pricing) has the details, and the [rate limits docs](https://docs.perceptlayer.com/rate-limits) describe how the limit is counted.

Every response also carries `RateLimit-Limit` (requests a minute), `RateLimit-Remaining`, `RateLimit-Reset` (seconds until the minute resets) and `RateLimit-Policy`. The code below reads `RateLimit-Limit` after each call, so it adapts if the account's rate changes mid-run instead of trusting a constant.

## How do you build the fetcher?

The fetcher has three parts: a `Pacer` that schedules request starts, a `fetch` coroutine that retries rate-limit responses, and a `run_batch` function that wires them to one shared `httpx.AsyncClient`. It targets `GET /v1/google-search/search`, which takes `query` as its only required parameter and costs 1 credit per successful call.

### Step 1: Install the client and set your key

Install `httpx` and export your key. Keys start with `sk_live_` and go in the `X-API-Key` header ( [authentication docs](https://docs.perceptlayer.com/authentication)). If you don't have one yet, [get a free API key](https://perceptlayer.com/signup): 1,000 free credits every month after you verify an email address, no credit card.

```bash
pip install httpx
export API_KEY=sk_live_your_key_here
```

### Step 2: Write the pacer

The pacer hands out start times. Each caller reserves the next slot under a lock, then sleeps until that slot arrives. `sync` converts `RateLimit-Limit` into an interval, with 10% headroom ( `HEADROOM = 0.9`) so jitter at a minute boundary doesn't push you over. `pause` pushes every future slot back, which is how a `429` slows the whole batch rather than one task.

```python
import asyncio
import os
import time

import httpx

BASE_URL = "https://api.perceptlayer.com"
PATH = "/v1/google-search/search"
MAX_IN_FLIGHT = 10
HEADROOM = 0.9
MAX_ATTEMPTS = 4

class Pacer:
    """Spaces request starts so the account stays under its per-minute limit."""

    def __init__(self, per_second=1.0):
        self.interval = 1 / per_second
        self._next = 0.0
        self._lock = asyncio.Lock()

    def sync(self, limit_per_minute):
        # RateLimit-Limit is requests a minute; keep some headroom under it.
        self.interval = 60 / (limit_per_minute * HEADROOM)

    def pause(self, seconds):
        self._next = max(self._next, time.monotonic() + seconds)

    async def wait(self):
        async with self._lock:
            now = time.monotonic()
            start = max(now, self._next)
            self._next = start + self.interval
        if start > now:
            await asyncio.sleep(start - now)
```

The pacer starts at 1 request a second, the free rate, and speeds up after the first response reports the real limit. A paying account therefore begins slowly for a handful of requests, then moves to its own rate. Starting low is the safe direction.

### Step 3: Fetch with retries

`fetch` takes a semaphore slot, waits for the pacer, sends the request and decides what the status means. The semaphore is acquired first, so no more than `MAX_IN_FLIGHT` tasks are ever waiting on the pacer or the network.

```python
async def fetch(client, sem, pacer, halt, query):
    async with sem:
        for attempt in range(1, MAX_ATTEMPTS + 1):
            if halt.is_set():
                return {"query": query, "status": "skipped"}
            await pacer.wait()
            try:
                r = await client.get(PATH, params={"query": query, "hl": "en"})
            except httpx.TimeoutException:
                # The call may have succeeded and been billed. Don't retry blindly.
                return {"query": query, "status": "unknown"}
            if "RateLimit-Limit" in r.headers:
                pacer.sync(int(r.headers["RateLimit-Limit"]))
            if r.status_code == 200:
                return {
                    "query": query,
                    "status": "ok",
                    "credits": int(r.headers.get("X-Credits-Used", 0)),
                    "data": r.json(),
                }
            if r.status_code in (429, 503):
                wait = float(r.headers.get("Retry-After", 1))
                pacer.pause(wait)
                continue
            if r.status_code in (401, 402):
                halt.set()
            return {"query": query, "status": r.status_code}
        return {"query": query, "status": "gave_up"}
```

`Retry-After` is a number of seconds on these responses (the header can also carry an HTTP date, per [RFC 9110](https://httpwg.org/specs/rfc9110.html#field.retry-after) and the [MDN reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After), but the API sends seconds). The `halt` event stops the batch on `401` ( `invalid_api_key`) or `402` ( `insufficient_credits`). Neither fixes itself, and sending the remaining thousands of requests would only produce more errors.

### Step 4: Run the batch

`run_batch` opens one client, so connections are reused, and gathers one `fetch` per query. A single `Pacer` and `Semaphore` are shared by every task.

```python
async def run_batch(queries):
    headers = {"X-API-Key": os.environ["API_KEY"]}
    async with httpx.AsyncClient(
        base_url=BASE_URL, headers=headers, timeout=30
    ) as client:
        sem = asyncio.Semaphore(MAX_IN_FLIGHT)
        pacer = Pacer(per_second=1.0)
        halt = asyncio.Event()
        return await asyncio.gather(
            *(fetch(client, sem, pacer, halt, q) for q in queries)
        )

if __name__ == "__main__":
    queries = [f"best restaurants in {city}" for city in ["Berlin", "Munich", "Hamburg"]]
    started = time.monotonic()
    results = asyncio.run(run_batch(queries))
    for r in results:
        if r["status"] == "ok":
            top = (r["data"].get("organic_results") or [{}])[0].get("title")
            print(f'{r["query"]!r}: {top}')
        else:
            print(f'{r["query"]!r}: {r["status"]}')
    spent = sum(r.get("credits", 0) for r in results)
    print(f"{len(results)} queries, {spent} credits, {time.monotonic() - started:.1f}s")
```

Save it as `fetch_batch.py` and run `python fetch_batch.py`. For lists in the tens of thousands, pass the queries in chunks of a thousand or so: `gather` creates one task per query up front, and chunking keeps memory flat and gives you a natural place to save progress.

## What does the result look like?

Each `ok` result holds the endpoint's JSON, and the script prints the first organic result's title plus a one-line summary of queries, credits and elapsed time. The `credits` figure is the sum of `X-Credits-Used` across successful calls, so it matches what the balance dropped by. For `best restaurants in Berlin`, the recorded response starts like this:

```json
{
  "search_information": {
    "query_displayed": "best restaurants in Berlin",
    "organic_results_count": 6
  },
  "organic_results": [
    {
      "position": 1,
      "title": "The Best Restaurants in Berlin - Food Guide & Map",
      "link": "https://andershusa.com/best-restaurants-berlin-germany-guide/",
      …
    }
  ],
  …
}
```

So the first line the script prints for that query is the `title` of `position` 1. Failed entries print their status instead: `unknown` for a timeout, `gave_up` after four rate-limited attempts, `skipped` once the batch halted, or the HTTP status for other errors. Rerun only the non- `ok` queries, and check `unknown` ones against your balance first (see [retrying without paying twice](https://perceptlayer.com/blog/retry-web-api-calls-without-paying-twice)).

The [Google Search `search` endpoint](https://perceptlayer.com/apis/google-search/search) takes many more optional parameters, such as `gl`, `location` and `device`. Add them to the `params` dict as needed.

## How long does a batch take and what does it cost?

Cost is the number of successful calls times 1 credit, and the minimum time is the number of calls divided by your rate. A `429` costs nothing, but each one adds a pause, so real runs finish a little after the minimum. The headroom setting adds roughly 11% to the times below (1 ÷ 0.9).

| Rate (requests a second) | 600 queries | 5,000 queries | 20,000 queries |
| --- | --- | --- | --- |
| 1 (free account) | 600 s (10 min) | over the 1,000 free credits | over the 1,000 free credits |
| 5 (Starter to Pro) | 120 s (2 min) | 1,000 s (about 17 min) | 4,000 s (about 67 min) |
| 10 (Scale) | 60 s | 500 s (about 8 min) | 2,000 s (about 33 min) |
| 20 (Volume) | 30 s | 250 s (about 4 min) | 1,000 s (about 17 min) |

Credits for those batches: 600 calls × 1 credit = 600 credits, inside the 1,000 free credits a month. 5,000 calls × 1 credit = 5,000 credits, inside the Starter pack's 10,000. 20,000 calls × 1 credit = 20,000 credits, which needs the Build pack's 50,000 or larger. The [credits docs](https://docs.perceptlayer.com/credits) explain billing, and [pricing](https://perceptlayer.com/pricing) shows what each pack costs.

If the same queries repeat across runs, caching saves both credits and time. See [caching API responses in Python](https://perceptlayer.com/blog/cache-web-data-api-responses).

## What should the fetcher do on errors?

Treat each status differently: wait and retry on `429` and `503`, stop on `401` and `402`, and don't retry blindly on a timeout. Failed calls cost nothing and return `X-Credits-Used: 0`, so retrying a `429` can't double-bill you. A timeout is the exception: there's no idempotency key, so a request whose reply was lost may have succeeded and been billed.

| Status | Code | Fetcher action |
| --- | --- | --- |
| 200 |  | Record the result and `X-Credits-Used` |
| 429 | `rate_limited` | Pause the pacer for `Retry-After` seconds, retry |
| 503 | `temporarily_unavailable` | Same as 429 |
| 401 | `invalid_api_key` | Halt the batch |
| 402 | `insufficient_credits` | Halt the batch; there is no automatic top-up |
| 422 | `invalid_parameters` | Return the status, fix the query |
| 502 | `source_error` | Return the status, retry the query later |
| Timeout | none | Mark `unknown`, compare `X-Credits-Remaining` before retrying |

Errors come back as JSON in the form `{"error": {"code", "message", "request_id"}}`; log the `request_id` with each failure. The [errors docs](https://docs.perceptlayer.com/errors) list every code, and [handling web data API errors in Python](https://perceptlayer.com/blog/handle-web-data-api-errors-python) goes deeper on the non-rate-limit cases.

If you page through results instead of sending many queries, the same pacer works unchanged: see [paginating a web data API in Python](https://perceptlayer.com/blog/paginate-web-data-api-python). For agents that call tools in parallel, [MCP server rate limiting](https://perceptlayer.com/blog/mcp-server-rate-limiting-agents) covers the same limits from the tool side.

Next step: [get a free API key](https://perceptlayer.com/signup), run the three-city script above, and watch `RateLimit-Remaining` fall as the batch runs.

## APIs in this post

-   [Google Search **1 credit per call** · 7 endpoints](https://perceptlayer.com/apis/google-search)

## Frequently asked questions

### Does a higher semaphore value make requests faster?

Only until you reach the account's rate. Past that point extra concurrency just queues behind the pacer, or earns `429` responses if you remove the pacer. The limit is requests per minute, so throughput is capped by your plan's rate however many connections you open.

### Do multiple API keys give me more requests per second?

No. Limits are per account and shared by all its keys, counted per minute. Two processes with two keys draw from the same budget, so they need a shared pacer or a split of the rate, such as half the requests a second each.

### Do \`429\` responses use up credits?

No. Rate-limited responses are failed calls, which cost nothing and return `X-Credits-Used: 0`. Each one still forces a pause, so a pacer that avoids them finishes sooner than one that relies on retries. Only a successful response debits credits.

### How many requests should I keep in flight?

Roughly your rate multiplied by your typical latency, plus a little slack. Measure latency from your own runs rather than assuming a figure. At 5 requests a second and a 1-second typical call, about 5 in flight keeps the pacer busy, so `MAX_IN_FLIGHT = 10` leaves room.

### Can I use aiohttp instead of httpx?

Yes. The pacer, semaphore and status handling don't depend on the HTTP client. Replace the `client.get` call and read the same headers ( `RateLimit-Limit`, `Retry-After`, `X-Credits-Used`) from the response, and the rest of the code stays the same.

python asyncio rate limits concurrency google search

## More from the blog

1.  [Data engineering](https://perceptlayer.com/blog/category/data-engineering) 22 Sept 2026 Perceptlayer 7 min read
    
    ## [Handle Web Data API Errors in Python: 4xx, 5xx and Timeouts](https://perceptlayer.com/blog/handle-web-data-api-errors-python)
    
    Map the API's error codes to retry, fix or stop in Python: 429 and 503 honour Retry-After, 4xx stops, and failed calls cost 0 credits.
    
2.  [Data engineering](https://perceptlayer.com/blog/category/data-engineering) 22 Aug 2026 Perceptlayer 10 min read
    
    ## [Cache API Responses in Python: TTLs and Credit Savings](https://perceptlayer.com/blog/cache-web-data-api-responses)
    
    A SQLite cache for API responses, keyed on endpoint and parameters, with per-endpoint TTLs and the credits each cache hit saves.
    
3.  [Data engineering](https://perceptlayer.com/blog/category/data-engineering) 20 Oct 2026 Perceptlayer 6 min read
    
    ## [Detect Schema Changes in API JSON Responses](https://perceptlayer.com/blog/web-data-api-json-schema-changes)
    
    Infer a path-and-type schema from a recorded Google Search response (71 paths), diff each new response against it and alert on removed or retyped fields.
