Algorithms¶
drogue provides five rate limiting algorithms. Each has different trade-offs for burst handling, accuracy, and memory usage.
Token Bucket¶
What it is¶
A bucket holds tokens that refill at a steady rate. Each request consumes tokens. When the bucket is empty, requests are denied. Allows bursts up to the bucket capacity.
How it works¶
Time 0s: Bucket = [■■■■■■■■■■] (10 tokens, full)
Time 1s: Request arrives, consumes 1 → [■■■■■■■■■□] (9 left)
Time 2s: Request arrives, consumes 1 → [■■■■■■■■□□] (8 left)
Time 3s: 2 tokens refill → [■■■■■■■■■■] (10 again)
Time 4s: 5 requests arrive fast → [■■■■■□□□□□] (5 left)
All 5 allowed (burst tolerance)
Time 5s: 3 more requests → denied (only 5 left, need 3... wait)
Visual example¶
Rate: 10 requests/minute, bucket capacity: 10
Time: 0s 10s 20s 30s 40s 50s 60s
| | | | | | |
Tokens: 10 → 10 → 10 → 5 → 5 → 8 → 10
↑ ↑ ↑
refill 5 req 3 req
(burst) (refill)
When to use¶
- APIs that allow occasional bursts (batch operations, page loads)
- Default choice for most applications
- When you want smooth rate limiting with burst tolerance
Example¶
from drogue.core.algorithms import TokenBucketAlgorithm
from drogue.core.storage.memory import MemoryStorage
storage = MemoryStorage()
await storage.initialize()
# 100 requests per minute, bucket refills steadily
algorithm = TokenBucketAlgorithm(storage=storage, limit=100, window=60.0)
# Burst: 10 requests instantly — all allowed
for i in range(10):
result = await algorithm.acquire("user123")
print(f"Request {i+1}: allowed={result.allowed}, remaining={result.remaining}")
# Request 1: allowed=True, remaining=9
# Request 2: allowed=True, remaining=8
# ...
# Request 10: allowed=True, remaining=0
# 11th request: denied (bucket empty)
result = await algorithm.acquire("user123")
print(f"Request 11: allowed={result.allowed}, retry_after={result.retry_after:.1f}s")
# Request 11: allowed=False, retry_after=0.6s
# Wait 0.6s, tokens refill
import asyncio
await asyncio.sleep(0.6)
result = await algorithm.acquire("user123")
print(f"After wait: allowed={result.allowed}, remaining={result.remaining}")
# After wait: allowed=True, remaining=0
Real-world scenario¶
# API endpoint: 1000 requests/hour per user
# User uploads a CSV with 500 rows → 500 requests in 10 seconds
# Token Bucket allows this burst (500 < 1000 capacity)
# After burst, user must wait for refill
@limiter.limit("1000/hour", algorithm=AlgorithmType.TOKEN_BUCKET)
async def process_csv():
# 500 rows processed in burst — allowed
...
Sliding Window Counter¶
What it is¶
Estimates the count in the current window by combining the previous and current window counts with a weighted formula. Eliminates the "boundary burst" problem of fixed windows.
How it works¶
Window size: 60 seconds
Limit: 100 requests
Previous window (12:00-12:01): 80 requests
Current window (12:01-now, 20 seconds elapsed):
estimated_count = 80 × ((60 - 20) / 60) + current_count
= 80 × 0.667 + current_count
= 53.3 + current_count
If current_count < 47 → allowed (53.3 + 47 = 100.3 ≈ limit)
Visual example¶
Rate: 100 requests/minute
Previous Window Current Window (20s elapsed)
[■■■■■■■■■■■■■■■■□□□□] [■■■■□□□□□□□□□□□□□□□□]
80/100 20/100
Weighted: 80 × (40/60) + 20 = 53.3 + 20 = 73.3
Remaining: 100 - 73 = 27 requests allowed
When to use¶
- Strict rate limiting where boundary bursts are unacceptable
- APIs that need accurate counting across window boundaries
- When you can tolerate slightly more memory (2 counters per key)
Example¶
from drogue.core.algorithms import SlidingWindowAlgorithm
algorithm = SlidingWindowAlgorithm(storage=storage, limit=100, window=60.0)
# 12:00:00 — send 100 requests (all allowed)
for i in range(100):
result = await algorithm.acquire("user123")
# All allowed
# 12:00:30 — 30 seconds later
# Previous window: 100, current window: 0
# estimated = 100 × ((60-30)/60) + 0 = 50
result = await algorithm.acquire("user123")
print(f"30s later: allowed={result.allowed}, remaining={result.remaining}")
# 30s later: allowed=True, remaining=49
# Allowed because estimated count (50) < limit (100)
# 12:00:45 — 45 seconds later
# estimated = 100 × ((60-45)/60) + 0 = 25
result = await algorithm.acquire("user123")
print(f"45s later: allowed={result.allowed}, remaining={result.remaining}")
# 45s later: allowed=True, remaining=74
Real-world scenario¶
# Login endpoint: 5 attempts per minute
# User tries at 12:00:55 (5 attempts), then at 12:01:05 (1 attempt)
# Fixed Window: Would allow 5 more at 12:01:05 (new window)
# Sliding Window: Only allows 0-1 (weighted estimate still high)
@limiter.limit("5/minute", algorithm=AlgorithmType.SLIDING_WINDOW)
async def login():
# Sliding window prevents boundary abuse
...
Fixed Window Counter¶
What it is¶
The simplest algorithm. Counts requests in fixed time windows (e.g., 12:00-12:01, 12:01-12:02). When the window resets, the counter resets to zero.
How it works¶
Window 1: 12:00:00 - 12:01:00
Count: 0 → 1 → 2 → ... → 100 → DENIED
Window 2: 12:01:00 - 12:02:00
Count: 0 → 1 → 2 → ... → 100 → DENIED
Visual example¶
Rate: 100 requests/minute
Time: 12:00:00 12:01:00 12:02:00
| | |
v v v
Window: [■■■■■■■■■■] [■■■■■■■■■■] [■■■■■■■■■■]
100 allowed 100 allowed 100 allowed
⚠️ Boundary problem:
12:00:59 → 100 requests
12:01:01 → 100 requests
= 200 requests in 2 seconds!
When to use¶
- Development/testing environments
- Simple rate limiting where boundary bursts don't matter
- When you need minimum memory usage (1 counter per key)
Example¶
from drogue.core.algorithms import FixedWindowAlgorithm
algorithm = FixedWindowAlgorithm(storage=storage, limit=100, window=60.0)
# Window: 12:00:00 - 12:01:00
for i in range(100):
result = await algorithm.acquire("user123")
# All allowed
# Request 101: denied
result = await algorithm.acquire("user123")
print(f"Request 101: allowed={result.allowed}")
# Request 101: allowed=False
# Window: 12:01:00 - 12:02:00
# Counter resets to 0
result = await algorithm.acquire("user123")
print(f"New window: allowed={result.allowed}, remaining={result.remaining}")
# New window: allowed=True, remaining=99
Real-world scenario¶
# Simple API: 1000 requests/hour
# Boundary bursts acceptable (most traffic is spread out)
@limiter.limit("1000/hour", algorithm=AlgorithmType.FIXED_WINDOW)
async def get_data():
# Simple, low memory, good enough for most cases
...
GCRA (Generic Cell Rate Algorithm)¶
What it is¶
A cell-based algorithm from telecom (ATM networks). Evenly spaces requests with no bursts allowed. Each request has a "theoretical arrival time" (TAT) — the earliest time the next request can arrive.
How it works¶
emission_interval = window / limit (time between allowed requests)
burst = limit (maximum burst size)
For each request:
new_tat = max(previous_tat, now) + emission_interval
allow_at = new_tat - burst × emission_interval
if allow_at ≤ now → allowed
else → denied (wait until allow_at)
Visual example¶
Rate: 10 requests/minute → emission_interval = 6 seconds
Time: 0s 6s 12s 18s 24s 30s
| | | | | |
✓ ✓ ✓ ✓ ✓ ✓
↑ ↑ ↑ ↑ ↑ ↑
evenly spaced, 6 seconds apart
Burst attempt at t=0:
5 requests at once → only 1 allowed (rest denied)
Next allowed at t=6s
When to use¶
- APIs requiring perfectly smooth traffic
- Telecom or streaming systems
- When bursts cause problems (rate-limited third-party APIs)
Example¶
from drogue.core.algorithms import GCRAAlgorithm
# 10 requests/minute, emission interval = 6 seconds
algorithm = GCRAAlgorithm(storage=storage, limit=10, window=60.0)
# Request 1: allowed (TAT = now + 6s)
result = await algorithm.acquire("user123")
print(f"Request 1: allowed={result.allowed}")
# Request 1: allowed=True
# Request 2 immediately: denied (TAT = now + 12s, allow_at = now + 6s)
result = await algorithm.acquire("user123")
print(f"Request 2: allowed={result.allowed}, retry_after={result.retry_after:.1f}s")
# Request 2: allowed=False, retry_after=6.0s
# Wait 6 seconds
import asyncio
await asyncio.sleep(6)
# Request 2: allowed (TAT updated)
result = await algorithm.acquire("user123")
print(f"After 6s: allowed={result.allowed}")
# After 6s: allowed=True
Real-world scenario¶
# Third-party API: strict 100 requests/minute, no bursts allowed
# GCRA ensures you never exceed, even temporarily
@limiter.limit("100/minute", algorithm=AlgorithmType.GCRA)
async def call_external_api():
# Smooth traffic, no bursts, safe for strict rate limits
...
Leaky Bucket¶
What it is¶
Requests fill a bucket that leaks at a constant rate. If the bucket is full, new requests are denied (or queued). Different from Token Bucket: Leaky Bucket processes at a constant rate with no bursts.
How it works¶
Bucket capacity: 10 (same as limit)
Leak rate: 10/60 = 0.167 requests/second
Time 0s: Bucket = 0 (empty)
→ 5 requests arrive → Bucket = 5 (all allowed)
Time 1s: Bucket leaks 0.167 → Bucket = 4.83
→ 3 requests arrive → Bucket = 7.83 (all allowed)
Time 2s: Bucket leaks 0.167 → Bucket = 7.66
→ 5 requests arrive → Bucket would be 12.66 > capacity(10)
→ 2 denied, 3 allowed
Visual example¶
Rate: 10 requests/minute, capacity: 10
Time: 0s 10s 20s 30s 40s 50s 60s
| | | | | | |
Water: 0 → 3 → 5 → 4 → 6 → 3 → 0
↑ ↑ ↑ ↑ ↑ ↑ ↑
fill leak fill leak fill leak leak
When to use¶
- APIs requiring constant-rate processing
- Queue-based systems (job queues, task processors)
- When you need to smooth out traffic spikes
Example¶
from drogue.core.algorithms import LeakyBucketAlgorithm
# 10 requests/minute, bucket capacity 10
algorithm = LeakyBucketAlgorithm(storage=storage, limit=10, window=60.0)
# Burst: 5 requests instantly
for i in range(5):
result = await algorithm.acquire("user123")
print(f"Request {i+1}: allowed={result.allowed}, water={10 - result.remaining}")
# Request 1: allowed=True, water=1
# Request 2: allowed=True, water=2
# ...
# Request 5: allowed=True, water=5
# 6 more requests (bucket would overflow)
for i in range(6):
result = await algorithm.acquire("user123")
if not result.allowed:
print(f"Request {i+6}: denied, retry_after={result.retry_after:.1f}s")
break
# Request 6: denied, retry_after=6.0s
Real-world scenario¶
# Job queue: process at most 10 jobs/minute
# Leaky Bucket ensures constant processing rate
# Bursts are queued, not processed immediately
@limiter.limit("10/minute", algorithm=AlgorithmType.LEAKY_BUCKET)
async def process_job():
# Constant rate, no bursts, smooth processing
...
Algorithm comparison¶
| Feature | Token Bucket | Sliding Window | Fixed Window | GCRA | Leaky Bucket |
|---|---|---|---|---|---|
| Bursts | Yes (up to capacity) | No | At boundaries | No | No |
| Accuracy | Good | Best | Good (except boundaries) | Good | Good |
| Memory | 2 values/key | 2 counters/key | 1 counter/key | 1 value/key | 2 values/key |
| Complexity | Low | Medium | Low | Low | Low |
| Use case | Most APIs | Strict rate limiting | Simple cases | Smooth traffic | Constant rate |
| Best for | General purpose | Login/security | Development | Third-party APIs | Job queues |
Choosing an algorithm¶
from drogue.core.algorithms import (
TokenBucketAlgorithm,
SlidingWindowAlgorithm,
FixedWindowAlgorithm,
GCRAAlgorithm,
LeakyBucketAlgorithm,
)
# Default: Token Bucket (good for most cases)
@limiter.limit("100/minute", algorithm=AlgorithmType.TOKEN_BUCKET)
# Strict: Sliding Window (no bursts, most accurate)
@limiter.limit("100/minute", algorithm=AlgorithmType.SLIDING_WINDOW)
# Simple: Fixed Window (lowest memory)
@limiter.limit("100/minute", algorithm=AlgorithmType.FIXED_WINDOW)
# Smooth: GCRA (evenly spaced, no bursts)
@limiter.limit("100/minute", algorithm=AlgorithmType.GCRA)
# Constant: Leaky Bucket (smooth processing)
@limiter.limit("100/minute", algorithm=AlgorithmType.LEAKY_BUCKET)
Common methods¶
All algorithms share these methods:
| Method | Signature | Description |
|---|---|---|
acquire |
(key, cost=1, block=False, timeout=None) |
Try to acquire a slot |
peek |
(key) |
Check state without consuming |
reset |
(key) |
Reset a key's state |
Blocking mode¶
Instead of denying immediately, wait for a slot:
# Wait up to 5 seconds for a slot
result = await algorithm.acquire("user123", block=True, timeout=5.0)
# If allowed immediately:
# AcquireResult(allowed=True, remaining=9, ...)
# If waited and got a slot:
# AcquireResult(allowed=True, remaining=0, ...)
# If timeout exceeded:
# AcquireResult(allowed=False, retry_after=3.2, ...)