← Back to DevBytes

How to Implement Rate Limiting for LLM APIs

How to Implement Rate Limiting for LLM APIs

Large Language Model (LLM) APIs are powerful, but they come with strict usage constraints. Whether you're building a chatbot, an AI-powered search tool, or an agent workflow, uncontrolled requests can quickly exhaust your quota, inflate costs, or trigger throttling errors. Rate limiting is the practice of controlling how many requests your application sends to an LLM provider within a given time window. This tutorial explains what rate limiting is, why it matters, and how to implement it effectively in production systems.

What Is Rate Limiting?

Rate limiting is a mechanism that restricts the number of actions a client can perform over a defined period. In the context of LLM APIs, it typically applies to two dimensions:

Providers like OpenAI, Anthropic, and Google enforce both limits. Hitting either one returns an HTTP 429 "Too Many Requests" response. A robust client must therefore track and throttle outgoing requests before they reach the provider.

Why Rate Limiting Matters

Without rate limiting, your application is vulnerable to several problems:

Implementing rate limiting on your side gives you predictable costs, smoother performance, and better control over user-facing behavior.

Common Rate Limiting Algorithms

Before writing code, it helps to understand the two most common algorithms:

The token bucket algorithm is generally the best fit for LLM APIs because it naturally accommodates bursts while respecting long-term averages.

Implementing a Token Bucket Limiter in Python

Below is a self-contained token bucket implementation that you can drop into any async Python project.

import time
import asyncio
from collections import deque

class TokenBucket:
    def __init__(self, rate: float, capacity: float):
        """
        rate: tokens added per second
        capacity: maximum tokens the bucket can hold
        """
        self.rate = rate
        self.capacity = capacity
        self.tokens = capacity
        self.updated_at = time.monotonic()
        self.lock = asyncio.Lock()

    async def acquire(self, tokens: float = 1.0) -> None:
        async with self.lock:
            while True:
                now = time.monotonic()
                elapsed = now - self.updated_at
                self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
                self.updated_at = now

                if self.tokens >= tokens:
                    self.tokens -= tokens
                    return

                # Wait until enough tokens are available
                deficit = tokens - self.tokens
                wait_time = deficit / self.rate
                await asyncio.sleep(wait_time)

The acquire method blocks until enough tokens are available, making it ideal for wrapping LLM calls. Here is how you would use it with an OpenAI-style client:

import openai

# Allow 50 requests per minute, with a burst capacity of 10
bucket = TokenBucket(rate=50 / 60, capacity=10)

async def chat_completion(messages):
    await bucket.acquire()
    response = await openai.ChatCompletion.acreate(
        model="gpt-4",
        messages=messages,
    )
    return response["choices"][0]["message"]["content"]

Handling HTTP 429 Responses with Retry

Even with client-side limiting, you should still handle 429 errors gracefully. Providers often return a Retry-After header indicating how long to wait. The following helper combines proactive throttling with reactive retries:

import asyncio
import random

async def call_with_retry(func, *args, max_retries=5, **kwargs):
    for attempt in range(max_retries):
        try:
            return await func(*args, **kwargs)
        except openai.error.RateLimitError as e:
            retry_after = getattr(e, "retry_after", None) or (2 ** attempt)
            jitter = random.uniform(0, 0.5)
            await asyncio.sleep(retry_after + jitter)
    raise RuntimeError("Max retries exceeded due to rate limiting")

Adding jitter prevents the "thundering herd" problem, where many retries fire simultaneously after a throttle window resets.

Token-Based Limiting

Requests-per-minute limiting is necessary but not sufficient. A single prompt with a large context can consume a huge portion of your token budget. To respect TPM limits, track token usage from each response and consume that many tokens from a separate bucket:

class TokenUsageBucket:
    def __init__(self, tokens_per_minute: int):
        self.rate = tokens_per_minute / 60
        self.capacity = tokens_per_minute
        self.bucket = TokenBucket(rate=self.rate, capacity=self.capacity)

    async def acquire(self, estimated_tokens: int):
        await self.bucket.acquire(estimated_tokens)

    async def reconcile(self, actual_tokens: int, estimated_tokens: int):
        # Refund the difference if we over-estimated
        diff = estimated_tokens - actual_tokens
        if diff > 0:
            self.bucket.tokens = min(
                self.bucket.capacity,
                self.bucket.tokens + diff
            )

Estimate token counts before the call using a tokenizer, then reconcile with the actual usage reported in the API response. This keeps your bucket accurate over time.

Distributed Rate Limiting

If your application runs across multiple processes or servers, in-memory buckets are not enough. You need a shared store. Redis is the standard choice because it offers atomic operations and low latency.

import redis.asyncio as redis
import time

class RedisTokenBucket:
    def __init__(self, client: redis.Redis, key: str, rate: float, capacity: float):
        self.client = client
        self.key = key
        self.rate = rate
        self.capacity = capacity

    async def acquire(self, tokens: float = 1.0) -> bool:
        script = """
        local key = KEYS[1]
        local rate = tonumber(ARGV[1])
        local capacity = tonumber(ARGV[2])
        local tokens_needed = tonumber(ARGV[3])
        local now = tonumber(ARGV[4])

        local data = redis.call('HMGET', key, 'tokens', 'timestamp')
        local current_tokens = tonumber(data[1]) or capacity
        local last_time = tonumber(data[2]) or now

        local elapsed = math.max(0, now - last_time)
        current_tokens = math.min(capacity, current_tokens + elapsed * rate)

        if current_tokens < tokens_needed then
            return 0
        end

        current_tokens = current_tokens - tokens_needed
        redis.call('HMSET', key, 'tokens', current_tokens, 'timestamp', now)
        redis.call('EXPIRE', key, 3600)
        return 1
        """
        now = time.time()
        result = await self.client.eval(
            script, 1, self.key,
            self.rate, self.capacity, tokens, now
        )
        return bool(result)

The Lua script runs atomically inside Redis, ensuring correctness even when many servers hit the same key simultaneously. Use a unique key per API key, per user, or per tenant depending on your isolation requirements.

Best Practices

Conclusion

Rate limiting is an essential layer of any production LLM integration. By combining a token bucket algorithm for proactive throttling, reactive retry logic for 429 handling, and a distributed store like Redis for multi-instance deployments, you can build a resilient system that stays within provider limits while delivering a smooth experience to your users. Start with a simple in-memory limiter, measure your actual traffic patterns, and evolve toward distributed limiting as your scale demands. The small upfront investment pays off in lower costs, fewer outages, and more predictable performance.

— Ad —

Google AdSense will appear here after approval

← Back to all articles