Your API Gateway Rate Limit Can't See the Agent Looping Your Search Tool
Here's the failure mode nobody's gateway catches.
An agent gets stuck. Not stuck like a crash — stuck like a loop: it calls web_search, gets results, doesn't like them, calls web_search again with a slightly different query. 10 times. 50 times. 400 times a minute. Each call is a perfectly valid HTTP request. Your API gateway sees normal traffic well under its per-key limit. Your bill disagrees.
The problem: gateway rate limits live at the HTTP layer. Agent abuse lives at the tool-call semantic layer. One HTTP request can fan out into many tool calls. One looping tool can spread its calls across many endpoints. A gateway counting bytes and paths never sees "the agent is hammering one expensive tool" — but that's exactly the unit an agent developer wants to budget.
I built the fuse that belongs at the tool-call layer.
What I built
tool-throttle — a zero-dependency Python package (stdlib only, Python ≥ 3.9) that puts a token-bucket rate limiter on MCP tool calls, per tool name. Wrap your tool dispatcher, set quotas, and over-quota calls get queued or rejected with a retry_after hint.
pip install tool-throttle
from tool_throttle import Throttler, ThrottlerConfig, Quota, REJECT
th = Throttler(ThrottlerConfig(
default=Quota(calls=60, per_seconds=60),
per_tool={
"web_search": Quota(calls=10, per_seconds=60), # tight leash
"expensive_*": Quota(calls=2, per_seconds=60), # glob patterns
},
on_exceed=REJECT, # or "queue": wait for a token, up to max_queue_wait
))
safe_search = th.wrap("web_search", mcp_client.call_tool)
safe_search("web_search", {"query": "..."}) # past quota -> ThrottledError
th.stats() # per-tool allowed / rejected / queued / waited_seconds
Quotas live in the language of the agent — tool names and glob patterns, with per-tool bursts — not in the language of HTTP. Config round-trips through JSON so it can sit in a file next to your agent config:
cfg = ThrottlerConfig(
default=Quota(calls=60, per_seconds=60),
per_tool={"web_*": Quota(calls=10, per_seconds=60, burst=3)},
on_exceed="queue",
max_queue_wait=30.0,
)
cfg.save("throttle.json")
There's also a CLI for quick checks and a demo:
echo '{"tool": "web_search"}' | tool-throttle check --config throttle.json
echo '{"tool": "web_search"}' | tool-throttle acquire --config throttle.json
tool-throttle demo # watch a looping agent get fused
How it works
Each distinct tool name gets its own TokenBucket, sized by the first matching quota in per_tool (glob patterns, insertion order) or default. Buckets refill steadily — Quota(calls=10, per_seconds=60) sustains 10 calls/minute with a burst of up to burst (default: calls).
On quota exhaustion:
-
reject— fail fast withThrottledError, carrying aretry_afterhint so the agent can back off intelligently instead of hammering blindly. -
queue— block until a token frees up, bounded bymax_queue_wait; if the bound would be exceeded, fail fast instead. Queuing keeps bursty-but-legitimate agents flowing while still capping the rate.
Everything is thread-safe, time is injectable (the test suite runs against a fake clock), and there are no dependencies — it's threading, time, json, fnmatch, and argparse.
Why this isn't just "rate limiting, again"
Because the layer matters:
- Gateway limits are per key/IP. An agent looping on one expensive tool looks normal at the HTTP layer while burning money at the tool layer. tool-throttle counts tool-call intent.
-
Tool granularity. You don't want the same budget for
get_timeandweb_search. Per-tool quotas with globs (expensive_*) express what you actually fear. - Fuse, not wall. Queuing with a bounded wait absorbs legitimate bursts instead of erroring on the first spike — it's a fuse that trips on sustained abuse, not a wall that punishes normal use.
-
Observability.
stats()tells you which tool the agent was abusing, with waited-time accounting. That's the number you want when you're debugging a runaway agent at 2am.
It complements gateway rate limiting; it doesn't replace it. The gateway protects your infrastructure. tool-throttle protects your wallet from your own agent.
Links
- GitHub: https://github.com/hahahahahahahahah6/tool-throttle
- PyPI: https://pypi.org/project/tool-throttle/
MIT licensed. Feedback welcome — especially war stories about agents you've caught looping.
Top comments (1)
A per-tool bucket catches the loop that hammers one name, but a stuck agent often rotates: web_search returns something it dislikes, then fetch_url, then web_search with one word changed. Each tool stays under its own quota while the run as a whole burns the budget. A second counter keyed on the whole run (total calls, or total spend if tools have a cost weight) next to the per-tool ones covers that case.
Two cheaper signals for a pure loop: a hash of the normalized arguments, where the 5th near-identical query in a window is the tell, and the
retry_aftertext going back into the model's context as a tool result it can read, not only as an exception the harness swallows.One edge: buckets live in process memory, so with several workers behind a dispatcher the real limit is quota times worker count. Is the state meant to be shared, or is one throttler per agent run the intended scope?