Rate Limiting at the API Gateway
In this tutorial, you'll learn about Rate Limiting at Gateway. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Rate limiting at the gateway controls how many requests a client can make within a time window, protecting backend services from abuse, traffic spikes, and denial-of-service attacks.
What You'll Learn
By the end of this lesson, you will implement token bucket and Sliding Window rate limiters, configure per-client limits, and integrate distributed rate limiting with Redis.
Why It Matters
Without gateway rate limiting, a single aggressive client can overwhelm all backend services. Gateway-level limits protect every service behind it.
Real-World Use
A gateway limits anonymous users to 10 requests per minute and authenticated users to 100 requests per minute, with different limits for different API endpoints.
Rate Limiting Architecture
flowchart TD
Client -->|Request| GW[Gateway]
GW --> RL{Rate Limiter}
RL -->|Under Limit| Backend[Backend Service]
RL -->|Over Limit| Reject[429 Too Many Requests]
RL --> Redis[(Redis Counter)]
style Reject fill:#f90,color:#fff
Token Bucket Algorithm
The token bucket is one of the most common rate limiting algorithms. Tokens refill at a constant rate, and each request consumes one token.
# token_bucket.py
import time
from typing import Dict
class TokenBucket:
def __init__(self, capacity: int, refill_rate: float):
self.capacity = capacity
self.refill_rate = refill_rate
self.tokens: Dict[str, dict] = {}
def allow(self, client_id: str) -> bool:
now = time.time()
if client_id not in self.tokens:
self.tokens[client_id] = {"tokens": self.capacity, "last_refill": now}
bucket = self.tokens[client_id]
elapsed = now - bucket["last_refill"]
bucket["tokens"] = min(self.capacity, bucket["tokens"] + elapsed * self.refill_rate)
bucket["last_refill"] = now
if bucket["tokens"] >= 1:
bucket["tokens"] -= 1
return True
return False
def remaining(self, client_id: str) -> float:
if client_id not in self.tokens:
return self.capacity
return self.tokens[client_id]["tokens"]
limiter = TokenBucket(capacity=5, refill_rate=1)
for i in range(8):
allowed = limiter.allow("client_1")
remaining = limiter.remaining("client_1")
print(f"Req {i+1}: {'ALLOWED' if allowed else 'BLOCKED'} ({remaining:.1f} tokens left)")
Expected output:
Req 1: ALLOWED (4.0 tokens left)
Req 2: ALLOWED (3.0 tokens left)
Req 3: ALLOWED (2.0 tokens left)
Req 4: ALLOWED (1.0 tokens left)
Req 5: ALLOWED (0.0 tokens left)
Req 6: BLOCKED (0.0 tokens left)
...
Req 8: BLOCKED (0.0 tokens left)
Sliding Window Log
# sliding_window.py
import time
from collections import defaultdict
from typing import Dict, List
class SlidingWindowLimiter:
def __init__(self, max_requests: int, window_seconds: int):
self.max_requests = max_requests
self.window_seconds = window_seconds
self.requests: Dict[str, List[float]] = defaultdict(list)
def allow(self, client_id: str) -> bool:
now = time.time()
cutoff = now - self.window_seconds
self.requests[client_id] = [t for t in self.requests[client_id] if t > cutoff]
if len(self.requests[client_id]) >= self.max_requests:
return False
self.requests[client_id].append(now)
return True
def remaining(self, client_id: str) -> int:
now = time.time()
cutoff = now - self.window_seconds
recent = [t for t in self.requests.get(client_id, []) if t > cutoff]
return max(0, self.max_requests - len(recent))
limiter = SlidingWindowLimiter(max_requests=3, window_seconds=10)
clients = ["user_a", "user_b", "user_a", "user_a", "user_a", "user_b"]
for client in clients:
allowed = limiter.allow(client)
remaining = limiter.remaining(client)
print(f"{client}: {'ALLOWED' if allowed else 'BLOCKED'} ({remaining} remaining)")
Expected output:
user_a: ALLOWED (2 remaining)
user_b: ALLOWED (2 remaining)
user_a: ALLOWED (1 remaining)
user_a: ALLOWED (0 remaining)
user_a: BLOCKED (0 remaining)
user_b: ALLOWED (1 remaining)
Distributed Rate Limiting with Redis
# redis_rate_limit.py
import time
import json
from typing import Dict, Optional
class RedisRateLimiter:
def __init__(self, window_seconds: int = 60):
self.window_seconds = window_seconds
self.store: Dict[str, dict] = {}
def increment(self, key: str) -> dict:
now = time.time()
window_key = int(now / self.window_seconds)
if key not in self.store or self.store[key].get("window") != window_key:
self.store[key] = {"window": window_key, "count": 0, "start": window_key * self.window_seconds}
self.store[key]["count"] += 1
return self.store[key]
def check(self, key: str, max_count: int) -> dict:
state = self.increment(key)
allowed = state["count"] <= max_count
remaining = max(0, max_count - state["count"])
reset_in = self.window_seconds - (time.time() - state["start"])
return {
"allowed": allowed,
"remaining": remaining,
"reset_in_seconds": int(reset_in),
}
limiter = RedisRateLimiter(window_seconds=60)
for i in range(6):
result = limiter.check("user:api_key_123", max_count=5)
print(f"Req {i+1}: {'ALLOWED' if result['allowed'] else 'BLOCKED'} "
f"(remaining: {result['remaining']}, reset: {result['reset_in_seconds']}s)")
Expected output:
Req 1: ALLOWED (remaining: 4, reset: 59s)
Req 2: ALLOWED (remaining: 3, reset: 59s)
Req 3: ALLOWED (remaining: 2, reset: 59s)
Req 4: ALLOWED (remaining: 1, reset: 59s)
Req 5: ALLOWED (remaining: 0, reset: 59s)
Req 6: BLOCKED (remaining: 0, reset: 59s)
Common Mistakes
1. Single Instance Rate Limiting
Rate limit state stored in memory is lost when the gateway restarts or scales. Use Redis for distributed state.
2. Not Sending Retry-After Headers
Clients need to know when they can retry. Always include the Retry-After header in 429 responses.
3. Same Limits for All Endpoints
Different endpoints have different costs. Apply stricter limits to expensive operations (search, exports) and looser limits to cheap ones (reads).
4. Counting Requests After Processing
Check rate limits early in the pipeline, before expensive processing. Reject over-limit requests immediately.
5. No Client Identification
Without identifying clients (API key, token, IP), rate limiting cannot distinguish between users. Implement proper client identification.
Practice Questions
1. What is the difference between token bucket and sliding window?
Token bucket allows bursts up to capacity and refills steadily. Sliding window counts requests in a moving time window, preventing bursts entirely.
2. Why distributed rate limiting needs Redis?
In-memory rate limit state is lost on restart and not shared across gateway instances. Redis provides shared, persistent counters.
3. What status code should a rate limiter return?
HTTP 429 Too Many Requests with a Retry-After header indicating when the client can retry.
4. How do you set different limits for different client tiers?
Map client tier (free, pro, enterprise) to limit settings. Check the tier before applying the limit.
Challenge
Design a multi-tier rate limiting system with Redis that applies 10 req/min for free, 100 req/min for pro, and 1000 req/min for enterprise, with different limits per endpoint.
FAQ
Mini Project: Multi-Tier Rate Limiter
# multi_tier_limiter.py
import time
from typing import Dict
class TierConfig:
def __init__(self, requests_per_minute: int, burst: int):
self.rpm = requests_per_minute
self.burst = burst
class MultiTierRateLimiter:
def __init__(self):
self.tiers = {
"free": TierConfig(10, 5),
"pro": TierConfig(100, 20),
"enterprise": TierConfig(1000, 100),
}
self.clients: Dict[str, dict] = {}
def allow(self, client_id: str, tier: str) -> dict:
config = self.tiers.get(tier, self.tiers["free"])
now = time.time()
if client_id not in self.clients:
self.clients[client_id] = {"tokens": config.burst, "last_refill": now, "tier": tier}
state = self.clients[client_id]
elapsed = now - state["last_refill"]
refill = elapsed * (config.rpm / 60)
state["tokens"] = min(config.burst, state["tokens"] + refill)
state["last_refill"] = now
if state["tokens"] >= 1:
state["tokens"] -= 1
return {"allowed": True, "remaining": int(state["tokens"]), "tier": tier}
return {"allowed": False, "remaining": 0, "tier": tier, "retry_after": 60 / config.rpm}
limiter = MultiTierRateLimiter()
test_clients = [("free_user", "free"), ("pro_user", "pro"), ("enterprise_user", "enterprise")]
for client_id, tier in test_clients:
results = [limiter.allow(client_id, tier)["allowed"] for _ in range(15)]
allowed = sum(results)
print(f"{tier:12s} user: {allowed}/15 allowed")
Expected output:
free user: 6/15 allowed (burst 5 + ~1 refill)
pro user: 15/15 allowed (burst 20)
enterprise user: 15/15 allowed (burst 100)
What's Next
You understand rate limiting. Next, learn about authentication at the gateway, then explore authorization at the gateway.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro