FastAPI rate limit dengan Redis token bucket
Rate limit FastAPI pakai Redis token bucket — atomic, distributed-friendly, support burst. Lebih akurat dari sliding counter.
Dipublikasikan 20 Juni 2026
API publik tanpa rate limit = undangan abuse. FastAPI sering deploy multi-instance di Kubernetes, jadi rate limit in-memory tidak cukup. Token bucket di Redis lebih akurat dari sliding counter dan handle burst dengan natural. Snippet ini middleware untuk per-IP dan per-user limit.
Kode
# rate_limit.py
import time
from dataclasses import dataclass
from typing import Optional
import redis.asyncio as redis
from fastapi import HTTPException, Request, status
# Lua script atomic — refill + consume token
TOKEN_BUCKET_LUA = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local data = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(data[1])
local last_refill = tonumber(data[2])
if tokens == nil then
tokens = capacity
last_refill = now
end
-- Refill berdasarkan elapsed time
local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + elapsed * refill_rate)
local allowed = 0
local retry_after = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
else
local kekurangan = cost - tokens
retry_after = math.ceil(kekurangan / refill_rate)
end
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 3600)
return {allowed, tokens, retry_after}
"""
@dataclass
class RateLimitResult:
allowed: bool
tokens_remaining: float
retry_after_seconds: int
class TokenBucketLimiter:
def __init__(
self,
redis_client: redis.Redis,
capacity: int,
refill_per_second: float,
prefix: str = "rl",
) -> None:
self.r = redis_client
self.capacity = capacity
self.refill = refill_per_second
self.prefix = prefix
self._script_sha: Optional[str] = None
async def _ensure_script(self) -> str:
if self._script_sha is None:
self._script_sha = await self.r.script_load(TOKEN_BUCKET_LUA)
return self._script_sha
async def consume(self, identifier: str, cost: int = 1) -> RateLimitResult:
sha = await self._ensure_script()
key = f"{self.prefix}:{identifier}"
now = time.time()
result = await self.r.evalsha(
sha, 1, key, self.capacity, self.refill, now, cost
)
allowed, tokens_remaining, retry_after = result
return RateLimitResult(
allowed=bool(allowed),
tokens_remaining=float(tokens_remaining),
retry_after_seconds=int(retry_after),
)
def get_identifier(request: Request) -> str:
"""Pakai user_id kalau authenticated, fallback ke IP."""
user = getattr(request.state, "user", None)
if user and hasattr(user, "id"):
return f"user:{user.id}"
# Behind proxy (nginx, CF) — trust X-Forwarded-For setelah validasi
ip = request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
ip = ip or request.client.host
return f"ip:{ip}"
async def rate_limit_dependency(
request: Request,
limiter: TokenBucketLimiter,
) -> None:
"""FastAPI Depends — raise 429 kalau exceeded."""
ident = get_identifier(request)
result = await limiter.consume(ident, cost=1)
if not result.allowed:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Rate limit exceeded",
headers={
"Retry-After": str(result.retry_after_seconds),
"X-RateLimit-Remaining": str(int(result.tokens_remaining)),
},
)
Pemakaian
# main.py
from contextlib import asynccontextmanager
import redis.asyncio as redis
from fastapi import Depends, FastAPI, Request
from rate_limit import TokenBucketLimiter, rate_limit_dependency
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.redis = redis.from_url("redis://localhost:6379/0", decode_responses=True)
# 100 token capacity, refill 10/detik → burst 100, sustain 10 req/s
app.state.limiter = TokenBucketLimiter(
app.state.redis, capacity=100, refill_per_second=10.0
)
yield
await app.state.redis.aclose()
app = FastAPI(lifespan=lifespan)
async def rate_limit(request: Request) -> None:
await rate_limit_dependency(request, request.app.state.limiter)
@app.get("/api/produk", dependencies=[Depends(rate_limit)])
async def list_produk():
return {"produk": ["Indomie", "Aqua", "Beng-Beng"]}
@app.get("/api/heavy", dependencies=[Depends(rate_limit)])
async def heavy_endpoint(request: Request):
# Endpoint berat — consume 5 token sekaligus
ident = request.headers.get("X-API-Key", "anon")
result = await request.app.state.limiter.consume(f"key:{ident}", cost=5)
if not result.allowed:
raise HTTPException(429, "Heavy endpoint exhausted")
return {"data": [...]}
# Test rate limit
for i in $(seq 1 120); do
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8000/api/produk
done
# Output: pertama 100 dapat 200, sisanya 429
Kapan dipakai
- API publik dengan free tier vs paid tier limit berbeda.
- Login endpoint — limit per IP supaya tidak brute force.
- AI / LLM endpoint — quota harian per user.
- Search endpoint yang berat di DB.
- Webhook receiver — protect dari flood upstream.
Catatan
- Lua script atomic — Redis eksekusi single-threaded, jadi script jalan tanpa race condition. Setara CAS tapi cleaner.
- script_load + evalsha — kirim Lua sekali, eksekusi pakai SHA hash. Hemat bandwidth.
- Capacity vs refill rate — capacity = burst tolerance, refill = sustained rate. User benefit kalau low traffic biasa, burst pas spike.
- X-Forwarded-For trust — hanya trust kalau di belakang proxy yang kamu kontrol (nginx, Cloudflare). Tanpa proxy, user spoof header dengan mudah.
- Headers response — kirim
Retry-AfterdanX-RateLimit-Remainingsupaya client bisa back-off dengan benar. - Multi-tier limit — combine per-IP (untuk anti abuse) + per-user (untuk plan limit) + per-endpoint (untuk expensive endpoint).
Token bucket gak handle “rate limit per route group” otomatis. Untuk skema kompleks (free user X req/menit ke /search, Y ke /upload), bikin limiter terpisah per group atau pakai library seperti
slowapi.
# tags
fastapirate-limitredistoken-bucketperformance
Ditulis oleh Asti Larasati · 20 Juni 2026