|$ curl https://forge-ai.dev/api/markdown?path=docs/ai/rate-limiting
$cat docs/rate-limiting.md
updated Recentlyยท25 min readยทpublished

Rate Limiting

โ—†AIโ—†Intermediate
Introduction

Rate limiting is essential for LLM applications to prevent abuse, manage costs, ensure fair resource allocation, and stay within API provider quotas. Unlike traditional rate limiting (which primarily protects infrastructure), LLM rate limiting also protects your budget โ€” each API call has a direct monetary cost proportional to token usage.

LLM APIs impose multiple rate limits simultaneously: requests per minute (RPM), tokens per minute (TPM), and concurrent requests. Exceeding these limits results in 429 (Too Many Requests) errors that must be handled gracefully with retry logic and backoff.

This guide covers the major rate limiting strategies โ€” token bucket, sliding window, concurrent request limiting, queue-based throttling โ€” along with backpressure mechanisms, retry algorithms, and production-grade middleware implementations.

Token Bucket Algorithm

The token bucket algorithm allows bursty traffic while enforcing a steady long-term rate. Tokens are added to a bucket at a fixed rate (the refill rate). Each request consumes one or more tokens. If the bucket is empty, the request is rejected or queued. Burst tolerance is determined by the bucket size.

token-bucket.py
Python
1import time
2import asyncio
3from typing import Optional
4
5class TokenBucket:
6 def __init__(self, rate: float, burst: int):
7 self.rate = rate # Tokens per second
8 self.burst = burst # Maximum bucket capacity
9 self.tokens = burst # Current tokens
10 self.last_refill = time.monotonic()
11 self._lock = asyncio.Lock()
12
13 async def _refill(self):
14 now = time.monotonic()
15 elapsed = now - self.last_refill
16 new_tokens = elapsed * self.rate
17 self.tokens = min(self.burst, self.tokens + new_tokens)
18 self.last_refill = now
19
20 async def acquire(self, tokens: int = 1) -> bool:
21 async with self._lock:
22 await self._refill()
23 if self.tokens >= tokens:
24 self.tokens -= tokens
25 return True
26 return False
27
28 async def wait_and_acquire(
29 self, tokens: int = 1, timeout: Optional[float] = None
30 ) -> bool:
31 deadline = time.monotonic() + timeout if timeout else None
32 while True:
33 if await self.acquire(tokens):
34 return True
35 if deadline and time.monotonic() >= deadline:
36 return False
37 # Wait for next token (at most 1/rate seconds)
38 await asyncio.sleep(1.0 / self.rate)
39
40# Usage
41bucket = TokenBucket(rate=10, burst=20) # 10 req/s, burst up to 20
42
43async def rate_limited_call(prompt: str) -> Optional[str]:
44 if await bucket.acquire():
45 return await llm_call(prompt)
46 return None # Rate limited
โ„น

info

The token bucket parameter ratio (burst / rate) determines burst tolerance. A ratio of 2 (burst=20, rate=10) allows doubling the sustained rate for up to 2 seconds. Set burst larger than your expected peak concurrency to avoid rate limiting legitimate bursts while maintaining long-term average limits.
Sliding Window

The sliding window algorithm tracks request timestamps within a rolling time window (e.g., the last 60 seconds). It provides more precise rate enforcement than token buckets because it does not allow "debt" accumulation โ€” the rate is strictly bounded over any window-sized interval.

sliding-window.py
Python
1from collections import deque
2import time
3
4class SlidingWindowRateLimiter:
5 def __init__(self, max_requests: int, window_seconds: int):
6 self.max_requests = max_requests
7 self.window_seconds = window_seconds
8 self.requests: deque = deque()
9
10 def allow_request(self) -> bool:
11 now = time.monotonic()
12 # Remove expired timestamps
13 while self.requests and self.requests[0] < now - self.window_seconds:
14 self.requests.popleft()
15
16 if len(self.requests) < self.max_requests:
17 self.requests.append(now)
18 return True
19 return False
20
21 def time_until_available(self) -> float:
22 if not self.requests:
23 return 0.0
24 if len(self.requests) < self.max_requests:
25 return 0.0
26 # Time until the oldest request expires
27 oldest = self.requests[0]
28 wait = (oldest + self.window_seconds) - time.monotonic()
29 return max(0.0, wait)
30
31# Multi-tier rate limiter
32class MultiTierRateLimiter:
33 def __init__(self):
34 self.limiters = {
35 "rpm": SlidingWindowRateLimiter(60, 60), # 60 RPM
36 "tpm": SlidingWindowRateLimiter(40000, 60), # 40K TPM
37 "concurrent": ConcurrentRequestLimiter(10), # 10 concurrent
38 }
39
40 def allow_request(self, estimated_tokens: int = 1000) -> bool:
41 if not self.limiters["rpm"].allow_request():
42 return False
43 if not self.limiters["tpm"].allow_request():
44 return False
45 if not self.limiters["concurrent"].allow_request():
46 return False
47 return True
Concurrent Request Limiting

Concurrent request limits restrict how many LLM calls can be in-flight simultaneously. This is critical because API providers enforce concurrent limits independently of RPM/TPM, and exceeding them causes connection-level errors rather than clean 429 responses.

concurrent-limiter.py
Python
1import asyncio
2from contextlib import asynccontextmanager
3
4class ConcurrentRequestLimiter:
5 def __init__(self, max_concurrent: int):
6 self.semaphore = asyncio.Semaphore(max_concurrent)
7 self.active_requests = 0
8
9 @asynccontextmanager
10 async def limit(self):
11 async with self.semaphore:
12 self.active_requests += 1
13 try:
14 yield
15 finally:
16 self.active_requests -= 1
17
18 async def __call__(self, fn, *args, **kwargs):
19 async with self.limit():
20 return await fn(*args, **kwargs)
21
22
23# Full rate-limited client
24class RateLimitedLLMClient:
25 def __init__(
26 self,
27 rpm: int = 60,
28 tpm: int = 40000,
29 max_concurrent: int = 10
30 ):
31 self.rpm_limiter = SlidingWindowRateLimiter(rpm, 60)
32 self.tpm_limiter = SlidingWindowRateLimiter(tpm, 60)
33 self.concurrent_limiter = ConcurrentRequestLimiter(max_concurrent)
34 self._queue = asyncio.Queue()
35 self._worker_task = None
36
37 async def call(self, prompt: str, max_tokens: int = 500) -> str:
38 estimated_tokens = len(prompt.split()) * 1.3 + max_tokens
39 await self._wait_for_capacity(estimated_tokens)
40 async with self.concurrent_limiter.limit():
41 response = await actual_llm_call(prompt, max_tokens)
42 return response
43
44 async def _wait_for_capacity(self, tokens: int, timeout: float = 30.0):
45 start = time.monotonic()
46 while time.monotonic() - start < timeout:
47 if (self.rpm_limiter.allow_request() and
48 self.tpm_limiter.allow_request()):
49 return
50 wait_rpm = self.rpm_limiter.time_until_available()
51 wait_tpm = self.tpm_limiter.time_until_available()
52 await asyncio.sleep(min(wait_rpm, wait_tpm, 0.1))
53 raise TimeoutError("Rate limit capacity not available")
Retry with Exponential Backoff

When the API returns a 429 (rate limit exceeded) or 5xx (server error), implement retry with exponential backoff and jitter. Never retry immediately โ€” this creates a thundering herd problem that worsens congestion. Add randomness (jitter) to prevent synchronized retry waves.

retry-backoff.py
Python
1import asyncio
2import random
3
4async def retry_with_backoff(
5 fn,
6 max_retries: int = 5,
7 base_delay: float = 1.0,
8 max_delay: float = 60.0,
9 jitter: bool = True
10):
11 last_exception = None
12 for attempt in range(max_retries):
13 try:
14 return await fn()
15 except (RateLimitError, ServerError) as e:
16 last_exception = e
17 if attempt == max_retries - 1:
18 raise
19
20 delay = base_delay * (2 ** attempt)
21 delay = min(delay, max_delay)
22
23 if jitter:
24 delay = delay * (0.5 + random.random())
25
26 # Respect Retry-After header if present
27 retry_after = getattr(e, "retry_after", None)
28 if retry_after:
29 delay = max(delay, float(retry_after))
30
31 print(f"Retry {attempt + 1}/{max_retries} "
32 f"after {delay:.1f}s: {e}")
33 await asyncio.sleep(delay)
34
35 raise last_exception
36
37
38# Decorator version
39def rate_limit_retry(max_retries=5, base_delay=1.0):
40 def decorator(fn):
41 async def wrapper(*args, **kwargs):
42 return await retry_with_backoff(
43 lambda: fn(*args, **kwargs),
44 max_retries=max_retries,
45 base_delay=base_delay
46 )
47 return wrapper
48 return decorator
49
50@rate_limit_retry(max_retries=3)
51async def get_completion(prompt: str) -> str:
52 # This call will automatically retry on 429/5xx
53 return await client.chat.completions.create(...)
โœ“

best practice

Always use jitter in your backoff calculations. Without jitter, distributed clients synchronize their retry timing, causing periodic congestion spikes. Full jitter (random between 0 and calculated delay) is recommended over equal jitter. Monitor your retry rate โ€” a high retry rate indicates your rate limits are set too aggressively or your traffic is too bursty.
Queue-Based Throttling & Backpressure

Queue-based throttling decouples request submission from rate-limited execution. Requests enter a queue and are processed at the rate allowed by API limits. Backpressure mechanisms prevent the queue from growing unboundedly by rejecting or shedding load when the queue exceeds capacity.

backpressure-queue.py
Python
1import asyncio
2from dataclasses import dataclass, field
3from typing import Optional
4
5@dataclass
6class QueueItem:
7 prompt: str
8 max_tokens: int
9 future: asyncio.Future = field(default_factory=asyncio.Future)
10
11class ThrottledQueue:
12 def __init__(self, rate_limiter, max_queue_size: int = 100):
13 self.rate_limiter = rate_limiter
14 self.queue: asyncio.Queue = asyncio.Queue(maxsize=max_queue_size)
15 self._worker_task = asyncio.create_task(self._worker())
16
17 async def submit(self, prompt: str, max_tokens: int = 500) -> str:
18 item = QueueItem(prompt=prompt, max_tokens=max_tokens)
19 try:
20 self.queue.put_nowait(item)
21 except asyncio.QueueFull:
22 raise BackpressureError("Queue full โ€” try again later")
23 return await item.future
24
25 async def _worker(self):
26 while True:
27 item = await self.queue.get()
28 # Wait for rate limit capacity
29 await self.rate_limiter.wait_for_capacity(item.max_tokens)
30 try:
31 result = await actual_llm_call(item.prompt, item.max_tokens)
32 item.future.set_result(result)
33 except Exception as e:
34 item.future.set_exception(e)
35
36# Backpressure-aware client
37class BackpressureError(Exception):
38 pass
39
40class AdaptiveRateLimiter:
41 def __init__(self, initial_rpm: int = 60):
42 self.current_rpm = initial_rpm
43 self.recent_429s = deque(maxlen=10)
44
45 async def call(self, fn):
46 while True:
47 if await self._allow():
48 try:
49 return await fn()
50 except RateLimitError:
51 self.recent_429s.append(time.monotonic())
52 self.current_rpm = max(10, int(self.current_rpm * 0.8))
53 await asyncio.sleep(self._backoff())
54 else:
55 if len(self.recent_429s) == 0:
56 self.current_rpm = min(1000, int(self.current_rpm * 1.1))
57
58 def _backoff(self) -> float:
59 recent = sum(
60 1 for t in self.recent_429s
61 if time.monotonic() - t < 60
62 )
63 return min(60.0, 2.0 ** recent)
Handling 429 Errors

429 Too Many Requests responses include a Retry-After header indicating how long to wait before retrying. Always respect this header โ€” it reflects the server's current congestion state. Ignoring it or retrying too aggressively can result in IP bans or account suspension.

429 Response Best Practices

โ—†Parse and respect Retry-After header (seconds or HTTP-date format)
โ—†Log all 429 responses with request ID and rate limit type
โ—†Alert if 429 rate exceeds threshold (e.g., > 5% of requests)
โ—†Implement circuit breaker after N consecutive 429s (stop sending, wait, probe)
โ—†Fallback to cached response or degraded mode when rate limited
โš 

warning

Do not retry indefinitely on 429 errors. Implement a circuit breaker that stops sending requests after a configurable number of consecutive 429s (e.g., 5 in 60 seconds). The circuit breaker should wait for a recovery period before probing with a single request. This prevents your system from making a bad situation worse during API provider outages.
$Blueprint โ€” Engineering DocumentationยทSection ID: AI-RL-01ยทRevision: 1.0