How to Rate Limit Calls to a Third-Party API
Cap concurrency, honour Retry-After and stop on a spend ceiling when you fan out calls to a third-party API, with code and measured runs.

Run a few hundred calls against a third-party API without tripping its limit or overspending. Paste this into your agent:
Set up https://monid.ai/SKILL.md, and then use Monid to run these 200 lookups with at most 5 in flight, wait out any Retry-After you get back, never retry a request the API called invalid, and stop the whole run if it would cost more than a dollar.
One prompt gives your agent the limiter, the retry rules and the spend stop. 1 min setup · 2-6 hrs saved.
You are calling someone else's API from a script or an agent, a few hundred calls at a time, and you want it to finish without being cut off or running up a bill. Most advice here is a backoff formula. This guide is about the calling side only (limiting an API you serve is a different job, done in a gateway): which control matters most, how to read what the API tells you when it pushes back, and what a capped run looked like when we ran one on 2026-10-16. It runs through Monid, the OpenRouter for agent tools: one key, one balance, every tool priced per call.
What is the easiest way to rate limit calls to a third-party API?
Give your agent the limiter as a standing instruction, and run the calls through Monid so every call is metered on one key. Install it once, add a key, and the agent discovers, inspects and runs endpoints while obeying a concurrency cap and a spend cap you set in plain words.
- Pay per call. Each call is billed on its own, and the two endpoints we ran for this guide, the TinyFish web search (covered in free web search for agents) at $0.00 a call and the Hunter multi-domain search at $0.00 a call, cost nothing (Verified 2026-10-16 on the web search tools page). Buy calls as you need them: no monthly plan and no minimum top-up. Discovery and inspection are free (see the agents tools page).
- One key, one balance. Every provider you call sits behind the same key, so the cap you set applies to the whole run, not to one vendor at a time. A spend ceiling is one number to watch.
- Lives inside your agent. Your agent reads
SKILL.mdand learns the discover, inspect and run steps itself, so setup is about a minute and nothing keeps running between jobs.
Monid does not rate limit your client for you. The limiter is a few lines of code or one sentence in your prompt, and the rest of this guide shows both.
How do you cap the calls you send, in a script or in an agent?
Cap how many calls are in flight at once, before you tune any delay, because concurrency is what a provider feels first.
For agents
Grab an API key at app.monid.ai, then paste this to your agent and hand it the key:
set up https://monid.ai/SKILL.md
It learns the whole discover, inspect, run workflow itself. More in the agent quickstart.
For humans
npm install -g @monid-ai/cli
monid keys add -k <your-api-key> -l main
Cap it in the shell
xargs -P is a concurrency cap in one flag. We ran three companies through the Hunter multi-domain search two at a time:
mkdir -p out
cat companies.txt | xargs -P 2 -I{} sh -c \
'monid run -p hunterio -e /multi-domain-search --query "{\"company_name\":\"{}\",\"department\":\"executive\",\"limit\":2}" -w -o "out/{}.json"'
All three finished in 3.8 seconds of wall clock, never more than two at once. Raise -P only when you have a reason.
Cap it in Python
import asyncio
sem = asyncio.Semaphore(5) # at most five calls in flight
async def call(client, params):
async with sem:
return await client.get(URL, params=params)
results = await asyncio.gather(*(call(client, p) for p in jobs))
The semaphore bounds parallelism. It does not bound the rate: five fast calls in flight can still be a lot of calls per second. If the provider documents a rate, add a token bucket on top, a counter that refills at the allowed rate and makes a call wait when it is empty.
Or hand the job to an agent
Give this to your agent![]()
Set up https://monid.ai/SKILL.md, and then use Monid to run these 200 lookups with at most 5 in flight, wait out any Retry-After you get back, never retry a request the API called invalid, and stop the whole run if it would cost more than a dollar.What should you do when the API answers 429 or 503?
Read the response before you wait, because the response usually says how long. A 429 means too many requests, and MDN's page for the status, which the TinyFish search returned first when we looked on 2026-10-16, says a Retry-After header may come with it to tell the client how long to wait. A 503 can carry the same header, so check both.
Classify first, then decide
Backoff only helps one kind of failure. A request the API called invalid will fail identically on every retry, and an origin that is down gets worse under retries. Sort every non-200 into a few bins and give each its own rule:
from email.utils import parsedate_to_datetime
import time
def wait_seconds(resp):
v = resp.headers.get("Retry-After")
if v is None:
return None
if v.isdigit():
return int(v) # seconds
return max(0, parsedate_to_datetime(v).timestamp() - time.time()) # HTTP date
def disposition(resp):
s = resp.status_code
if s == 429 or (s == 503 and "Retry-After" in resp.headers):
return "wait", wait_seconds(resp) or 60 # the server's number first
if s in (500, 502, 503, 504):
return "provider_fault", None # retry once, then alert
if s in (400, 401, 403, 404, 422):
return "our_request", None # never retry
if "json" not in resp.headers.get("content-type", ""):
return "not_an_api_response", None # a redirect or an error page
return "ok", None
The last branch is the one people leave out. A client that checks only status codes will parse an HTML error page as JSON and report a mystery. It is the same silent-failure shape a blocked scraper produces: a clean 200 with nothing useful inside.
React at the pool, not the request
When one call is told to wait, every other call in the pool is about to be told the same. Sleep for the server's number, then lower the cap for everyone:
kind, wait = disposition(resp)
if kind == "wait":
await asyncio.sleep(wait)
cap = max(1, cap // 2) # halve on a signal ...
elif ok_streak >= 50:
cap = min(MAX_CAP, cap + 1) # ... and recover one step at a time
Halving on a signal and growing by one on a long clean run is additive increase, multiplicative decrease, the same shape TCP congestion control uses. With a semaphore, resize it by replacing the permits you hand out, not by sleeping inside each task.
Add jitter only when there is no number
If the response gives no Retry-After, wait a growing, randomised interval: one second doubled each attempt, capped at a minute, so a hundred workers do not wake together. When the response gives a number, use it and skip the formula.
Should you write the limiter yourself?
Yes for one script, and no for a standing pipeline you do not want to maintain. A semaphore, a classifier and a pool-level reaction are about sixty lines. The cost shows up later: the limiter has to live outside the process once you run several workers, and it has to change every time you add a provider with its own way of saying no.
Handing the job to an agent moves that work. The prompt at the top of this page asks for a cap, a classification rule and a stop, and the agent writes and applies them for each run. Every endpoint is described in a form the agent reads with monid inspect, price included, so it knows what a call costs before it sends one, and every call bills to the same balance, so the stop condition is a single sum.
What does a capped run look like?
A paged lookup that returns a handful of rows per call, a cursor for the next page, and a number telling you how many pages exist. We ran the Hunter multi-domain search (the domain search versus email finder guide covers the rest of that provider) on 2026-10-16 with company_name set to Stripe and Notion, department set to executive and limit set to 5:
{
"data": [ { "name": "Jane U.", "position": "Managing Director",
"department": "executive", "type": "personal",
"provenance": "found", "decision_maker": true,
"domain": "stripe.com",
"linkedin_exists": true,
"verification": { "date": null, "status": "valid" },
"reveal_handle": "mvOH..." } ],
"meta": { "results": 47, "next_search_after": "-e-HCs3j..." }
}
It returned 5 rows of 47 matches, billed $0.00 (Verified 2026-10-16), and answered in about a second. The addresses stay hidden behind a reveal_handle. To fetch the next page, send the same query with search_after set to the cursor from the last response:
monid run -p hunterio -e /multi-domain-search -w \
--query '{"company_name":"Stripe,Notion","department":"executive","limit":5,"search_after":"<next_search_after>"}'
That second call returned five different rows, the same total of 47, and a new cursor. The cursor changes on every page, so a paging loop is sequential by design: you cannot send pages 2, 3 and 4 in parallel, and the endpoint hands you a natural cap of one call in flight per query. Run different queries side by side and page each one in series.
A search endpoint is the other shape. We ran the TinyFish search with the query Retry-After header 429 too many requests restricted to developer.mozilla.org:
monid run -p tinyfish -e /search -w \
--query '{"query":"Retry-After header 429 too many requests","include_domains":"developer.mozilla.org"}'
It returned ten ranked results in about two seconds, each with position, title, url, site_name and a snippet, billed $0.00 (Verified 2026-10-16). One call, ten answers: fewer, larger calls are a better answer to a rate limit than faster, smaller ones.
What goes wrong when you fan out calls?
Four things hide until you run more than a handful in parallel. The first one we hit on 2026-10-16 ourselves.
Why did a metadata call get rate limited?
Because every call counts, free ones included. After we ran many endpoints in parallel earlier that day, monid inspect, which is free and only reads a schema, answered "Rate limited" for several minutes. The lesson is the one from the first section: the cap belongs on everything your pool sends, not just the calls you think of as the real ones. Inspect each endpoint once per run, not before every call.
Why do the totals not add up?
Check what a count means before you budget on it. In the Hunter search the Stripe query matched 41 people, Figma 11 and Notion 6, and the combined Stripe-and-Notion query reported 47, which is 41 plus 6. The company filter is a name match, so "Notion" matched notion.online and notion.nl, not the company you probably meant. Filter on the domain field in the rows, because a budget built on a loose match covers the wrong pages.
Why do the rows repeat?
Two of the five rows on the first page were identical in every visible field: the same abbreviated name, the same co-founder title, the same provenance of inferred. They differed only in reveal_handle. Dedupe before you pay to reveal anything, because retries and overlapping pages multiply duplicates.
Why did half the search results say the same thing?
Six of the ten TinyFish results were translations of the same two MDN pages, in German, French, Russian, Korean, Portuguese and Japanese. The endpoint has a language parameter. Set the language and the domain list on the way in rather than dropping rows afterwards.
What does a retry loop cost you?
On a free endpoint, nothing but time. On a priced one, every retry is a billed call. Put the spend check before dispatch, and make it halt rather than delay:
if spent + price_per_call * len(batch) > DAILY_CAP:
raise SystemExit("cap reached") # stop, do not slow down
Pacing a runaway loop only spends the money more slowly. monid inspect prints the unit price before you run, so the estimate costs nothing to make.
Which endpoint should I use for which job?
None of these is a rate limiter, because the limit has to live in your client, next to the work you dispatch. These are the endpoints worth pairing with one, because one call replaces many.
| Endpoint | What it does | Input | Billing | Price |
|---|---|---|---|---|
tinyfish/search | Ranked web results with a snippet each | query, include_domains, language, page | Per call | $0.00 (Verified 2026-10-16) |
hunterio/multi-domain-search | People across many companies, cursor paged | company_name, department, limit, search_after | Per call | $0.00 (Verified 2026-10-16) |
context.dev/web/search | Search with optional Markdown of each result | query, numResults (10 to 100) | Per result | $0.000253 (Verified 2026-10-16) |
Prices Verified 2026-10-16 with monid inspect. The first two we ran; the third we inspected and did not run. Its price is per result with a floor of ten results, so a default call is about $0.00253 and a hundred-result call about $0.0253. Use the first for a quick check, and the second when you need a population before you spend on individual records. Use the third when you want the page content in the same call.
📖 See also Brave Search API: what you can keep
When is another route a better fit?
You serve the API. Limiting calls into your own service is a gateway job (LLM gateway versus MCP gateway compares two), with its own quotas per key and per route, and none of this guide applies.
The provider publishes a rate and a higher tier. For sustained high volume the answer is often a conversation with the vendor, and an agreed limit beats one discovered by tripping it.
You are blocked, not limited. A challenge page returned with HTTP 200 is not a pacing problem, and slowing down will not fix it. That failure looks nothing like a 429.
And the disclosure: this is the Monid blog and Monid sells per-call access to the endpoints above. Monid does not rate limit your client and does not normalise how different providers say no. What it changes is that the constraint becomes spend, a single number you can bound across providers.
Conclusion
To rate limit calls to a third-party API, cap how many are in flight, read the response before you wait, and put a hard stop on spend. A semaphore of five and a pool that halves on a limit signal cover most of it, and through Monid the same cap applies across every provider on one key.
Two things matter more than the algorithm. Classify before you retry: honour the Retry-After the server gives you, retry a provider fault once, and never retry a request the API called invalid. And remember that every call counts, free ones too: we ran into a "Rate limited" answer from a free metadata call on the day we wrote this, after many parallel runs.
How we estimated the time saved: writing, testing and wiring a concurrency cap, a response classifier and a spend stop is about 2 to 6 hours for one pipeline, and an agent with the prompt above does it in the first run. The 1 minute is the install, key and first call once you already have a Monid key. Free next step: run monid inspect -p hunterio -e /multi-domain-search, make one call with limit set to 5, and read meta.results and next_search_after before you plan the loop. Start at monid.ai.
FAQ
What is the difference between a 429, a 503 and a 403?
A 429 means too many requests, so slow down, and it may carry a Retry-After header that says how long. A 503 means the service is unavailable and can carry the same header, so read it before you decide. A 403 is a refusal, and retrying it at any speed will not change the answer.
Should I use a semaphore, a token bucket or a leaky bucket?
Start with a semaphore, because concurrency is the control a provider feels first and it is a few lines of code. Add a token bucket when you have a documented requests-per-second number and want short bursts inside it. A leaky bucket smooths bursts into a steady rate, which suits a fragile provider. The two controls are independent, so a tuned bucket does not stop two hundred connections opening at once.
How do I rate limit across several workers?
Keep the counter outside the process. Five workers each capped at five in flight send twenty-five at once, so share one counter, for example in Redis with a short expiry. The failure to avoid is autoscaling behind a per-process limiter, where your real rate grows with your worker count.
How many times should a call be retried?
Two or three attempts is enough for a transient fault, and none for a request the API called invalid. Cap total retries as a share of all requests, around ten percent, so a wide outage cannot triple your traffic while the provider struggles. A wait the server asked for is not an attempt, but your own guess is.
Published September 2026.


