Blog / Data engineering

Concurrent API Requests in Python: Asyncio Under Rate Limits

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.

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).

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. 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 has the details, and the rate limits docs 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). If you don't have one yet, get a free API key: 1,000 free credits every month after you verify an email address, no credit card.

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.

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.

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 and the MDN reference, 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.

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:

{
  "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).

The Google Search search endpoint 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 explain billing, and 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.

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 list every code, and handling web data API errors in 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. For agents that call tools in parallel, MCP server rate limiting covers the same limits from the tool side.

Next step: get a free API key, run the three-city script above, and watch RateLimit-Remaining fall as the batch runs.

APIs in this post

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.

  1. Handle Web Data API Errors in Python: 4xx, 5xx and Timeouts

    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. Cache API Responses in Python: TTLs and Credit Savings

    A SQLite cache for API responses, keyed on endpoint and parameters, with per-endpoint TTLs and the credits each cache hit saves.

  3. Detect Schema Changes in API JSON Responses

    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.

Start with 1,000 free credits

No password to set. We email you a sign-in link, and your key works on every API.

No credit card. 1,000 free credits a month.