Patrón Token Bucket Rate Limiter
Cómo implementar token bucket rate limiting para API protection. Cubre bucket refill, burst handling, per-key buckets, distributed rate limiting con Redis, y sliding windows.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Overview
El token bucket rate limiter controla request rate maintainiendo un bucket de tokens. Cada request consume un token. Los tokens se refilléan a un fixed rate. Si el bucket está empty, el request es rejected o delayed. El bucket tiene un capacity, alloweando bursts hasta el capacity mientras maintainéa un average rate igual al refill rate. A diferencia de fixed-window limiters, token buckets allowéan bursts sin resetear en window boundaries. Esto los hace ideal para API rate limiting donde clients occasionally necesitan burstear pero deberían maintainar un average rate.
When to Use
- API rate limiting per client o per API key
- Protecteando downstream services de traffic spikes
- Controlando outbound call rate a external APIs
- Multi-tenant systems donde cada tenant tiene un rate quota
- Scenarios donde bursts deberían ser allowed pero average rate controlled
When NOT to Use
- Strict per-second limits sin burst tolerance (usá fixed window)
- Cuando necesitás exact counts per time period (usá sliding window)
- Simple internal throttling donde precision no importa
Solution
Basic token bucket (Python)
# rate_limiter/token_bucket.py — In-memory token bucket rate limiter
import time
import threading
from collections import defaultdict
class TokenBucket:
"""Single token bucket con configurable rate y capacity."""
def __init__(self, rate, capacity):
"""
Args:
rate: tokens per second (refill rate)
capacity: max tokens in bucket (burst size)
"""
self.rate = rate
self.capacity = capacity
self._tokens = capacity
self._last_refill = time.monotonic()
self._lock = threading.Lock()
def _refill(self):
now = time.monotonic()
elapsed = now - self._last_refill
new_tokens = elapsed * self.rate
self._tokens = min(self.capacity, self._tokens + new_tokens)
self._last_refill = now
def try_consume(self, tokens=1):
"""Try consumir tokens. Returnéa True si allowed, False si rejected."""
with self._lock:
self._refill()
if self._tokens >= tokens:
self._tokens -= tokens
return True
return False
def consume(self, tokens=1, timeout=None):
"""Consumí tokens, blockeando hasta available o timeout."""
while True:
with self._lock:
self._refill()
if self._tokens >= tokens:
self._tokens -= tokens
return True
if timeout is not None:
wait_time = (tokens - self._tokens) / self.rate
if wait_time > timeout:
return False
timeout -= wait_time
time.sleep(wait_time)
else:
time.sleep(tokens / self.rate)
@property
def available_tokens(self):
with self._lock:
self._refill()
return self._tokens
class RateLimiter:
"""Manageéa per-key token buckets.
Cada API key, user, o IP obtiene su propio bucket."""
def __init__(self, default_rate=10, default_capacity=20):
self.default_rate = default_rate
self.default_capacity = default_capacity
self._buckets = {}
self._lock = threading.Lock()
def configure(self, key, rate, capacity):
"""Configurá un custom rate para un specific key."""
with self._lock:
self._buckets[key] = TokenBucket(rate, capacity)
def allow(self, key, tokens=1):
"""Checkeá si el request es allowed para el given key."""
with self._lock:
if key not in self._buckets:
self._buckets[key] = TokenBucket(
self.default_rate, self.default_capacity
)
bucket = self._buckets[key]
return bucket.try_consume(tokens)
def get_info(self, key):
"""Obtené bucket info para un key."""
with self._lock:
if key not in self._buckets:
return None
bucket = self._buckets[key]
return {
"available": bucket.available_tokens,
"rate": bucket.rate,
"capacity": bucket.capacity
}
# Usage
limiter = RateLimiter(default_rate=10, default_capacity=20)
# Custom rate para premium API key
limiter.configure("premium-key", rate=100, capacity=200)
def api_request(api_key):
if not limiter.allow(api_key):
return {"error": "Rate limit exceeded"}, 429
return {"data": "success"}, 200
Distributed token bucket con Redis (Python)
# rate_limiter/redis_token_bucket.py — Distributed rate limiter usando Redis
import redis
import time
class RedisTokenBucket:
"""Distributed token bucket usando Redis.
Funciona across múltiples server instances."""
def __init__(self, redis_url="redis://localhost:6379"):
self._redis = redis.from_url(redis_url)
def allow(self, key, rate, capacity, tokens=1):
"""Atómicamente consumí tokens usando Redis Lua script.
Returnéa (allowed, remaining_tokens, retry_after_seconds)."""
script = """
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local tokens_requested = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local ttl = math.ceil(capacity / rate * 2)
-- Get current bucket state
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local current_tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now
-- Refill tokens
local elapsed = math.max(0, now - last_refill)
local new_tokens = elapsed * rate
current_tokens = math.min(capacity, current_tokens + new_tokens)
-- Try to consume
local allowed = 0
if current_tokens >= tokens_requested then
current_tokens = current_tokens - tokens_requested
allowed = 1
end
-- Save state
redis.call('HMSET', key, 'tokens', current_tokens, 'last_refill', now)
redis.call('EXPIRE', key, ttl)
local retry_after = 0
if allowed == 0 then
retry_after = math.ceil((tokens_requested - current_tokens) / rate)
end
return {allowed, math.floor(current_tokens), retry_after}
"""
now = time.time()
result = self._redis.eval(
script, 1, f"ratelimit:{key}",
rate, capacity, tokens, now
)
allowed = bool(result[0])
remaining = int(result[1])
retry_after = int(result[2])
return allowed, remaining, retry_after
# Usage — shared across múltiples server instances
limiter = RedisTokenBucket("redis://redis:6379")
def handle_request(api_key):
allowed, remaining, retry_after = limiter.allow(
api_key, rate=10, capacity=20
)
if not allowed:
return {"error": "Rate limit exceeded",
"retry_after": retry_after}, 429
return {"data": "ok", "remaining": remaining}, 200
Java token bucket con Guava
// RateLimiterConfig.java — Java rate limiting con Guava RateLimiter
import com.google.common.util.concurrent.RateLimiter;
import java.util.concurrent.ConcurrentHashMap;
public class ApiRateLimiter {
private final ConcurrentHashMap<String, RateLimiter> limiters = new ConcurrentHashMap<>();
private final double defaultRate;
public ApiRateLimiter(double defaultRate) {
this.defaultRate = defaultRate;
}
public boolean allow(String key) {
RateLimiter limiter = limiters.computeIfAbsent(
key, k -> RateLimiter.create(defaultRate)
);
return limiter.tryAcquire();
}
public boolean allow(String key, int permits) {
RateLimiter limiter = limiters.computeIfAbsent(
key, k -> RateLimiter.create(defaultRate)
);
return limiter.tryAcquire(permits);
}
public void configure(String key, double rate) {
limiters.put(key, RateLimiter.create(rate));
}
public void acquire(String key) {
RateLimiter limiter = limiters.computeIfAbsent(
key, k -> RateLimiter.create(defaultRate)
);
limiter.acquire(); // Blockea hasta permit available
}
}
Express middleware (JavaScript)
// rate_limiter/express-middleware.js — Token bucket middleware para Express
const express = require("express");
class TokenBucket {
constructor(rate, capacity) {
this.rate = rate;
this.capacity = capacity;
this.tokens = capacity;
this.lastRefill = Date.now();
}
tryConsume(tokens = 1) {
this._refill();
if (this.tokens >= tokens) {
this.tokens -= tokens;
return true;
}
return false;
}
_refill() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.rate);
this.lastRefill = now;
}
get available() {
this._refill();
return Math.floor(this.tokens);
}
}
class RateLimiterMiddleware {
constructor(defaultRate = 10, defaultCapacity = 20) {
this.defaultRate = defaultRate;
this.defaultCapacity = defaultCapacity;
this.buckets = new Map();
}
middleware() {
return (req, res, next) => {
const key = req.headers["x-api-key"] || req.ip;
let bucket = this.buckets.get(key);
if (!bucket) {
bucket = new TokenBucket(this.defaultRate, this.defaultCapacity);
this.buckets.set(key, bucket);
}
if (bucket.tryConsume(1)) {
res.setHeader("X-RateLimit-Remaining", bucket.available);
res.setHeader("X-RateLimit-Limit", this.defaultCapacity);
next();
} else {
res.setHeader("X-RateLimit-Remaining", 0);
res.setHeader("X-RateLimit-Limit", this.defaultCapacity);
res.setHeader("Retry-After", 1);
res.status(429).json({
error: "Rate limit exceeded",
retryAfter: 1
});
}
};
}
}
// Usage
const app = express();
const limiter = new RateLimiterMiddleware(10, 20);
app.use("/api/", limiter.middleware());
Sliding window counter (Python)
# rate_limiter/sliding_window.py — Sliding window rate limiter
import time
from collections import defaultdict
class SlidingWindowRateLimiter:
"""Sliding window counter: combina current y previous window.
Más accurate que fixed window, menos memory que sliding log."""
def __init__(self, window_size=60, max_requests=100):
self.window_size = window_size
self.max_requests = max_requests
self._current = defaultdict(int)
self._previous = defaultdict(int)
self._window_start = time.time()
self._lock = threading.Lock()
def allow(self, key):
with self._lock:
now = time.time()
elapsed = now - self._window_start
# Rotateá windows si es needed
if elapsed >= self.window_size:
self._previous = self._current.copy()
self._current = defaultdict(int)
self._window_start = now
elapsed = 0
# Weighted count: current window + weighted previous window
weight = 1 - (elapsed / self.window_size)
count = self._current[key] + self._previous[key] * weight
if count >= self.max_requests:
return False
self._current[key] += 1
return True
Variants
Per-tier rate limiting
# rate_limiter/tiers.py — Different rates per subscription tier
class TieredRateLimiter:
"""Rate limits basado en subscription tier.
Free: 10 req/s, Pro: 100 req/s, Enterprise: 1000 req/s."""
TIER_CONFIG = {
"free": {"rate": 10, "capacity": 20},
"pro": {"rate": 100, "capacity": 200},
"enterprise": {"rate": 1000, "capacity": 2000},
}
def __init__(self):
self._limiter = RateLimiter()
def allow(self, api_key, tier="free"):
config = self.TIER_CONFIG.get(tier, self.TIER_CONFIG["free"])
# Configurá en first use
info = self._limiter.get_info(api_key)
if info is None:
self._limiter.configure(api_key, config["rate"], config["capacity"])
return self._limiter.allow(api_key)
Rate limiter con queue
# rate_limiter/queued.py — Queueéa excess requests en vez de rejectear
import queue
import threading
class QueuedRateLimiter:
"""En vez de rejectear, queueéa requests y proceséa al configured rate."""
def __init__(self, rate, capacity, max_queue=100):
self._bucket = TokenBucket(rate, capacity)
self._queue = queue.Queue(maxsize=max_queue)
self._running = True
self._worker = threading.Thread(target=self._process, daemon=True)
self._worker.start()
def _process(self):
while self._running:
item = self._queue.get()
self._bucket.consume(1)
item["callback"](item["request"])
self._queue.task_done()
def submit(self, request, callback):
"""Submiteá un request. Returnéa False si queue está full."""
try:
self._queue.put_nowait({"request": request, "callback": callback})
return True
except queue.Full:
return False
def stop(self):
self._running = False
Best Practices
-
For a deeper guide, see API Gateway Design: Resilience, Routing, and Security.
-
Seteá rate headers —
X-RateLimit-Limit,X-RateLimit-Remaining,Retry-Afterhelpéan a clients self-regulate -
Usá distributed rate limiting para multi-instance deployments — Redis-based, no in-memory
-
Returneá 429 con Retry-After — standard HTTP status para rate limiting
-
Allowéa bursts — token bucket’s capacity handleéa legitimate bursts sin rejectear
-
Usá per-key buckets — per API key, per user, per IP, no global
-
Seteá bucket TTL — cleanupéa inactive buckets para prevenir memory growth
-
Monitoreá rejection rate — high rejection rates indican misconfigured limits
-
Testeá under load — verificá que el limiter behave correctamente en el rate boundary
Common Mistakes
- Global rate limit: todos los clients shareéan un bucket. Un noisy client exhaustéa el limit para todos.
- No Retry-After header: clients no saben cuando retryear y pollean agresivamente.
- In-memory only en multi-instance: cada instance tiene su propio counter, alloweando N× el rate.
- Capacity equals rate: no burst tolerance. Seteá capacity a 2-3× el rate para burst handling.
- No cleanup de old buckets: memory crece indefinitely. Seteá TTL en inactive buckets.
FAQ
¿Qué es el token bucket algorithm?
Un rate limiting algorithm donde un bucket holdéa tokens, refilléados a un fixed rate. Cada request consume un token. Si el bucket está empty, el request es rejected. El bucket capacity allowéa bursts hasta el capacity mientras maintainéa el average refill rate.
¿En qué se diferencia token bucket de fixed window?
Fixed window resetéa el counter en window boundaries (e.g., cada minuto). Token bucket continuamente refilléa, alloweando bursts en cualquier time. Token bucket es más smooth; fixed window tiene edge cases en boundaries donde clients pueden burstear 2× el rate.
¿Qué debería ser el bucket capacity?
Seteá capacity a 2-3× el refill rate. Esto allowéa short bursts mientras maintainéa el average rate. Por ejemplo, rate=10/s, capacity=30 allowéa 30 requests instantly, luego 10/s sustained.
¿Cómo hago rate limiting across múltiples server instances?
Usá un shared store como Redis. El Redis Lua script atómicamente reads, refills, y updates el bucket. Todas las instances shareéan el mismo bucket state. In-memory buckets solo funcionan para single-instance deployments.
¿Debería rejectear o queueear rate-limited requests?
Para APIs, rejecteá con 429 + Retry-After. Queueear agrega latency y complexity. Para background jobs, queueear es appropriate — proceséa al configured rate sin rejectear.
Recursos Relacionados
Bulkhead Pattern: Isolate Resources to Limit Blast Radius
How to isolate resources per service to limit blast radius. Covers thread pool isolation, connection pool partitioning, semaphore-based bulkheads, and resource quotas.
PatternRetry with Jitter: Exponential Backoff and Random Jitter
How to retry failed operations with exponential backoff and random jitter. Covers full jitter, equal jitter, decorrelated jitter, retry budgets, and idempotency.
PatternCircuit Breaker Half-Open
How to test service recovery with half-open circuit breaker state transitions. Covers closed, open, half-open states, trial requests, and gradual recovery.