27.6. API Rate Limiting
Table of Contents
Why API Rate Limiting Matters
Modern backend systems often serve thousands or millions of requests per day. Without any control, a single buggy client or a malicious attacker could:
- Use up all your server resources
- Make your API slow or unavailable for others
- Cause you to exceed paid quotas on third‑party services
Rate limiting is how you control how many requests a client can make in a given time window. It is a key part of API reliability, cost control, and security.
Core idea of rate limiting
Limit the number of allowed actions (for example, requests) per subject (for example, user, IP, API key) in a given time window to protect your system.
Common goals of rate limiting:
- Protect your API from abuse and DoS attacks
- Ensure fair usage between clients
- Enforce pricing tiers and quotas
- Protect expensive resources, for example database, external APIs
- Provide predictable performance for all users
Typical rules look like:
- “100 requests per minute per IP”
- “1000 requests per day per user”
- “10 login attempts per hour per account”
What Exactly Is Being Limited?
You must first decide what you are limiting and for whom.
What you limit
Examples:
- Number of HTTP requests
- Number of login attempts
- Number of “send email” operations
- Number of “create order” operations
- Number of WebSocket messages sent
You do not always rate limit every endpoint equally. Some endpoints are more expensive:
/searchmight be more expensive than/healthPOST /ordershas more business impact thanGET /products
It is common to have different limits per endpoint or endpoint group.
Who you limit (the key)
The “subject” that you count against is often called the rate limit key.
Typical keys:
| Key type | Example value | When to use |
|---|---|---|
| IP address | 203.0.113.10 | Public APIs without auth, anonymous users |
| User ID | user_123 | Authenticated APIs |
| API key / token | sk_live_abc... | Third‑party client apps or partners |
| Device ID | device_abc123 | Mobile apps, IoT devices |
| Account/org ID | company_42 | Multi‑tenant SaaS, team‑level limits |
You can also combine them:
- “Limit per user per endpoint”
- “Limit per IP and per API key, whichever hits first”
Important rule
Always choose a rate limit key that matches your security and fairness goals. Do not rely on IP only if you really care about user‑level abuse.
Types of Rate Limiting Rules
Per second, minute, hour, day
The most common style: “X requests per Y time”.
Examples:
- 10 requests per second
- 100 requests per minute
- 10,000 requests per day
Short windows handle bursts. Long windows handle overall quotas.
You can combine them:
- “10 req/s AND 1000 req/day per user”
Per endpoint or operation
Examples:
GET /products: 120 req/minPOST /orders: 20 req/minPOST /login: 5 req/15min per username
Login attempts are a good example: you might allow very few attempts to reduce brute force risk.
Per plan or tier
In paid APIs, rate limits often depend on the subscription plan:
| Plan | Limit |
|---|---|
| Free | 1000 requests per day |
| Pro | 10,000 requests per day |
| Business | 100 requests per second, 1M per day |
The plan is part of the rate limit key, for example: plan:pro:user_123.
Basic Rate Limiting Algorithms
Internally, rate limiting is done by algorithms that count events in time.
1. Fixed window counter
You choose a window like 1 minute and a max count like 100. For each key you store a counter per window.
Example logic:
- Compute the current window: for example,
window_id = floor(current_timestamp / 60)for 1‑minute windows. - Counter key:
rate:{user_id}:{window_id} - Increment the counter on each request.
- If counter > limit, reject.
Simple example in pseudocode:
def allow_request(key: str, limit: int, window_seconds: int) -> bool:
window_id = int(time.time()) // window_seconds
counter_key = f"rate:{key}:{window_id}"
count = increment_counter(counter_key, expire=window_seconds)
return count <= limitPros:
- Simple to understand
- Easy to implement with Redis
INCRandEXPIRE
Cons:
- Has boundary problems. A client can send 100 requests at the end of one minute and 100 requests at the start of the next minute, effectively 200 requests in just over a minute.
This “burst at the window boundary” can be a problem for strict systems.
2. Sliding window log
You store timestamps of each request and count only those within the last X seconds.
Algorithm idea:
- For each request, store
timestampin a list for the key. - Remove timestamps older than
window_seconds. - If list length > limit, reject.
Pseudocode:
def allow_request(key: str, limit: int, window_seconds: int) -> bool:
now = time.time()
timestamps = get_timestamps(key)
timestamps = [t for t in timestamps if now - t <= window_seconds]
if len(timestamps) >= limit:
return False
timestamps.append(now)
save_timestamps(key, timestamps)
return TruePros:
- Accurate sliding window, no boundary jumps.
Cons:
- Stores many timestamps, more memory.
- More operations per request.
In practice, Redis sorted sets (ZADD, ZREMRANGEBYSCORE, ZCARD) can help implement this efficiently.
3. Sliding window counter / hybrid
A compromise between fixed window counter and full logs.
Idea:
- Keep counters for the current and previous windows.
- Use interpolation to estimate how many requests fall in the last X seconds.
This smooths out boundary spikes without storing all timestamps.
You might see it called “sliding window” in many libraries.
4. Token bucket
Very popular, especially for APIs and network traffic shaping.
Imagine a bucket that:
- Has a maximum capacity
Ctokens, for example 100. - Refills at a constant rate
R, for example 1 token per second. - Each request consumes 1 token.
If the bucket has tokens, the request is allowed and a token is removed. If it is empty, the request is rejected (or delayed).
Properties:
- Allows bursts up to capacity
C. - Long‑term rate is limited by refill rate
R.
Pseudocode:
def allow_request(key: str, capacity: int, refill_rate: float) -> bool:
state = load_state(key) # {tokens, last_refill_timestamp}
now = time.time()
# Refill tokens based on elapsed time
elapsed = now - state.last_refill_timestamp
refill = elapsed * refill_rate
tokens = min(capacity, state.tokens + refill)
if tokens < 1:
# not enough tokens
save_state(key, tokens, now)
return False
tokens -= 1
save_state(key, tokens, now)
return TruePros:
- Very common and well understood.
- Easy to implement in distributed systems with Redis or similar.
- Allows nice bursts while controlling average rate.
Cons:
- Requires careful handling of floating point or uses integer “microtokens”.
5. Leaky bucket
Very similar conceptually to token bucket, but described as:
- A queue (bucket) that drips out at a constant rate.
- Incoming requests are added to the bucket.
- If the bucket is full, extra requests are dropped or delayed.
In most API use cases token bucket and leaky bucket give similar behavior.
Where to Store Rate Limiting State
Rate limiting needs to remember counts for each key. Where you store this state affects correctness and scalability.
In memory of a single server
For a small single‑instance backend, you can store counters in memory.
Example with a Python dict (simplified):
from collections import defaultdict
import time
counters = defaultdict(list) # key -> list of timestamps
def allow_request_in_memory(key: str, limit: int, window_seconds: int) -> bool:
now = time.time()
timestamps = [t for t in counters[key] if now - t <= window_seconds]
counters[key] = timestamps
if len(timestamps) >= limit:
return False
counters[key].append(now)
return TrueLimitations:
- If the server restarts, counters reset.
- If you run multiple instances behind a load balancer, each instance has separate counters. A client may bypass the limit by hitting different servers.
This is acceptable for dev, not for serious production.
Shared in-memory store (Redis)
Redis is the most common choice:
- Very fast in-memory database
- Supports atomic operations, for example
INCR,SETNX, Lua scripts - Can be shared between all app instances
A fixed window counter with Redis:
import time
import redis
r = redis.Redis()
def allow_request_redis(key: str, limit: int, window_seconds: int) -> bool:
window_id = int(time.time()) // window_seconds
redis_key = f"rate:{key}:{window_id}"
# INCR and EXPIRE should be atomic, usually done with a pipeline or script
with r.pipeline() as pipe:
pipe.incr(redis_key, 1)
pipe.expire(redis_key, window_seconds)
count, _ = pipe.execute()
return count <= limitThis works across multiple app servers as long as they all talk to Redis.
Database
You can store counters in PostgreSQL or another DB, but:
- Writes are slower than Redis.
- High load can put pressure on the DB, which is often your most critical component.
This is usually used for long‑term quotas, not for high‑frequency per second limits.
Example use:
- Daily usage per user: store in a
usagetable, update counters asynchronously.
CDN / API gateway
Sometimes you do not implement rate limiting in your application at all. Instead you:
- Put your API behind a reverse proxy, CDN, or API gateway (for example, Nginx, Cloudflare, AWS API Gateway, Kong).
- Configure rate limiting rules there.
Advantages:
- Offloads traffic from your app.
- Often very efficient and distributed.
Disadvantages:
- You have less flexibility, for example complex business‑specific rules may be harder to express.
- Rate limit rules may live in a different system from your app logic.
In many real systems you will combine gateway‑level rate limiting with app‑level rules.
Designing Practical Rate Limits
Choosing sensible limits
You must balance:
- User experience: Too strict limits will frustrate users or break legitimate integrations.
- Resource protection: Too loose limits will not protect your system.
A simple way to start:
- Estimate how many requests a normal user makes.
- Multiply by a safety factor, for example 3 or 5.
- Apply a short‑term limit and a long‑term quota.
Example for a small SaaS API:
- Per user:
- 10 requests per second
- 10,000 requests per day
- Per IP:
- 50 requests per second, mostly for anonymous clients
Different limits per use case
Examples:
- Public unauthenticated endpoints: Strict IP‑based limits, for example 60 req/min/IP.
- Authenticated API: Higher user‑based limits, for example 600 req/min/user.
- Login: 5 attempts per 15 minutes per username, plus 20 attempts per 5 minutes per IP.
- Heavy operations: Endpoints that trigger background jobs or expensive DB queries may have lower limits, for example 10/min.
Multiple layers of limits
You can combine several rules:
- Global limit for your entire API, for example “no more than 1000 req/s total.”
- Per customer limit.
- Per IP limit for unauthenticated traffic.
- Per operation limit for sensitive actions like password reset or payment.
A request is denied as soon as it breaks any of the applied rules.
Communicating Rate Limits to Clients
An important aspect of rate limiting is how you tell clients about their limits and how close they are to the limit.
HTTP status code
When a request is rejected due to rate limiting, you should use:
Mandatory status code for rate limiting
Use HTTP status code 429 Too Many Requests when a request is rejected because of rate limits.
Example response:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 60
{
"detail": "Rate limit exceeded. Try again in 60 seconds."
}
The Retry-After header tells clients how long to wait before retrying. The value can be:
- A number of seconds
- Or a HTTP date, for example
Wed, 21 Oct 2026 07:28:00 GMT
Rate limit headers
It is best practice to tell the client:
- What the limit is
- How many requests they have used
- When the limit resets
A common pattern inspired by GitHub and others:
| Header | Meaning | Example |
|---|---|---|
X-RateLimit-Limit | Max requests allowed in the window | 100 |
X-RateLimit-Remaining | Requests left in the current window | 42 |
X-RateLimit-Reset | Unix timestamp when the window resets | 1727443200 |
Retry-After | Seconds until you can retry (on 429 responses) | 60 |
Example response when under limit:
HTTP/1.1 200 OK
Content-Type: application/json
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1727443200
{
"data": "..."
}Example response when limit exceeded:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1727443200
Retry-After: 60
{
"detail": "Rate limit exceeded. Try again in 60 seconds."
}Clearly exposing this information helps API consumers write polite, backoff‑aware clients.
Example: Simple Token Bucket in a FastAPI Service
To make this concrete, here is a simplified example that uses:
- Redis to store rate limit state.
- A token bucket to limit each API key to 5 requests per 10 seconds.
Assume you are already comfortable with FastAPI basics.
import time
from typing import Optional
import redis
from fastapi import FastAPI, Request, HTTPException, Depends
app = FastAPI()
r = redis.Redis(host="localhost", port=6379, db=0)
class RateLimitState:
def __init__(self, tokens: float, last_refill: float):
self.tokens = tokens
self.last_refill = last_refill
def get_api_key(request: Request) -> str:
api_key = request.headers.get("X-API-Key")
if not api_key:
# In a real app, you might allow some endpoints without a key
raise HTTPException(status_code=401, detail="Missing API key")
return api_key
def rate_limiter(
api_key: str = Depends(get_api_key),
capacity: int = 5,
refill_interval_seconds: int = 10,
):
"""
Token bucket:
- Max 5 tokens
- Bucket refills to full every 10 seconds (so 0.5 tokens/second)
"""
bucket_key = f"bucket:{api_key}"
now = time.time()
# Load existing state from Redis (simple JSON-encoded string for example)
data: Optional[bytes] = r.get(bucket_key)
if data is None:
# First request: full bucket
state = RateLimitState(tokens=capacity, last_refill=now)
else:
tokens_str, last_refill_str = data.decode().split(",")
state = RateLimitState(tokens=float(tokens_str), last_refill=float(last_refill_str))
# Refill tokens
elapsed = now - state.last_refill
refill_rate = capacity / refill_interval_seconds # tokens per second
new_tokens = state.tokens + elapsed * refill_rate
state.tokens = min(capacity, new_tokens)
state.last_refill = now
if state.tokens < 1:
# No tokens available
# Calculate wait time until next token is available
missing = 1 - state.tokens
wait_seconds = int(missing / refill_rate) + 1
headers = {"Retry-After": str(wait_seconds)}
raise HTTPException(
status_code=429,
detail=f"Rate limit exceeded. Try again in {wait_seconds} seconds.",
headers=headers,
)
# Consume a token and save state
state.tokens -= 1
r.setex(bucket_key, refill_interval_seconds * 2, f"{state.tokens},{state.last_refill}")
# Optionally you can return info for headers
remaining = int(state.tokens)
return {
"limit": capacity,
"remaining": remaining,
"reset_in": refill_interval_seconds,
}
@app.get("/data")
def get_data(limit_info=Depends(rate_limiter)):
# You could expose rate info in headers, or just ignore it here
return {"message": "OK", "rate_limit": limit_info}Example of using it:
- Client sends a request with
X-API-Key: test123. - First 5 requests in a 10 second period succeed.
- 6th request during that period returns
429 Too Many RequestswithRetry-Afterheader.
This is simplified. In production you would:
- Use structured storage for state, not plain
tokens,last_refillstrings. - Wrap rate limiting into reusable utilities or middleware.
- Set headers like
X-RateLimit-Limit,X-RateLimit-Remaining. - Consider multiple limits per API key.
Handling Distributed Systems
When your backend runs on multiple instances, rate limiting must work across all of them.
Key considerations:
Use a shared store
All instances must read and update the same counters. That is why Redis is used so often.
Bad pattern:
- Each instance keeps its own local counters.
- A client can make twice as many requests by hitting two instances.
Good pattern:
- All instances talk to the same Redis cluster or similar.
Atomic operations
When many instances try to update the same counter at the same time, you must avoid race conditions.
Approaches:
- Use Redis commands that are atomic by definition, for example
INCR. - Use Redis transactions (
MULTI/EXEC) or Lua scripts to update multiple keys atomically. - In other stores, use transactions or row‑level locks.
Handling network and store failures
If Redis is down:
- Do you allow all traffic, risking overload?
- Or block all traffic, risking downtime?
There is no perfect answer. Common strategies:
- For critical user‑facing APIs: allow traffic but log an alert, and possibly use a fallback in‑memory limit.
- For sensitive operations like login or payments: be conservative and block or drastically limit traffic when store is unavailable.
Whatever you choose, document it clearly.
Rate Limiting and Client Behavior
Rate limiting only works well if clients behave reasonably.
Backoff strategies
Clients should not:
- Send 100 retries per second when they receive 429 responses.
Instead they should:
- Use
Retry-Afterheaders when present. - Use exponential backoff, for example wait 1s, 2s, 4s, 8s between retries.
- Use jitter (small random variation) to avoid synchronized retry storms.
A simple algorithm for a client:
- Make a request.
- If response status is 429:
- If
Retry-Afterheader exists, wait that many seconds. - Else, wait an increasing backoff delay.
- Try again up to some max number of retries.
Idempotency and retries
When clients retry due to rate limits or timeouts, you want to avoid doing the same side‑effect twice, for example creating two orders.
This is where idempotency (covered in a separate chapter) and rate limiting interact:
- Rate limit errors should happen before any side‑effects.
- Idempotent operations are safer to automatically retry when rate limits cause delays.
Special Use Cases
Protecting login endpoints
Login is a classic target for brute force attacks.
Typical strategy:
- Limit per username, for example 5 login attempts per 15 minutes.
- Limit per IP address, for example 20 login attempts per 5 minutes.
- Possibly add additional rules for known proxy IP ranges.
If a user gets blocked, you might show:
{
"detail": "Too many login attempts. Please wait a few minutes before trying again."
}And send status code 429.
Protecting expensive resources
Some endpoints cost more:
- Search queries that scan large tables.
- Report generation.
- File processing, image resizing, video encoding.
You can apply very low limits for these:
- 2 report generations per minute per user.
- 10 video encodes per hour per account.
Here the rate limit protects CPU, memory, and third‑party services, not only your API endpoint.
Free vs paid tier
When you monetize your API, rate limiting becomes part of your business model.
You might store limits per plan:
plan:free -> 1000 requests/day
plan:pro -> 10000 requests/day
plan:biz -> 100000 requests/dayIn your app, after authenticating the user and loading their plan, you select a different limit.
Pseudocode:
def get_limits_for_plan(plan: str) -> tuple[int, int]:
if plan == "free":
return (10, 1000) # 10 req/min, 1000 req/day
if plan == "pro":
return (60, 10000)
if plan == "business":
return (200, 100000)
return (5, 500) # default fallbackCommon Pitfalls and Best Practices
Pitfalls
- Only per IP limits
- Users behind NAT or large organizations may share one IP, causing unfair blocking.
- Attackers can rotate IPs.
- Too strict default limits
- Perfectly valid workloads get blocked.
- Clients build hacks to spread traffic across tokens or IPs.
- Not documenting rate limits
- Clients are surprised by 429 errors.
- Hard to debug integration issues.
- No observability
- No metrics or logs about how often limits are hit.
- Hard to know if limits are too high, too low, or abused.
- Ignoring time synchronization
- Fixed windows rely on accurate server time.
- If servers disagree on time, behavior can be unpredictable.
Best practices
- Start simple. Begin with fixed window or a basic token bucket.
- Expose headers. Use
X-RateLimit-*andRetry-After. - Log and monitor. Collect metrics on:
- Number of requests blocked by limits
- Top keys hitting limits
- Trends over time
- Use shared storage for distributed systems, typically Redis.
- Apply multiple levels (per IP, per user, per plan, per endpoint).
- Whitelist / override internal or trusted services while still keeping some protection.
- Document behavior in your API docs: limits, status codes, headers, and expected client behavior.
Summary
API rate limiting is about controlling how often clients can perform actions so that your backend stays reliable, fair, and secure.
Key points:
- Choose what to limit and who to limit, then pick suitable keys and windows.
- Use algorithms like fixed window, sliding window, and especially token bucket for practical implementations.
- Store counters in a shared, fast store like Redis for distributed systems.
- Communicate with clients using 429 Too Many Requests,
Retry-After, andX-RateLimit-*headers. - Apply different rules for different endpoints, users, and plans, and monitor their impact.
With these concepts, you can design and implement effective rate limiting in your own backend APIs and understand how to integrate with APIs that expose rate limits to you.
Views: 7
KAHIBARO