Python ve Redis ile Hız Sınırlayıcı Yazmak
Merhabalar, bu yazımızda Python ile bir API üzerinde hız sınırlayıcı (rate limiter) kurmaya bakacağız ve sayacı tutmak için Redis kullanacağız. Konu basit gibi duruyor ama içine girince 'pencere ne zaman sıfırlanır, iki instance arasında sayaç nasıl paylaşılır, burst trafiği nasıl tolere edilir' gibi sorular üst üste gelmeye başlıyor. Hadi başlayalım.
Neden Redis?
Tek bir uygulama instance'ı varsa Python'ın kendi belleğinde bir sözlük tutmak yeterli görünür. Gel gör ki gerçek hayatta neredeyse hiç tek instance ile gitmiyoruz; arkada iki tane gunicorn worker'ı varsa bile her biri kendi sayacını tutar ve kullanıcı toplamda iki katı isteği rahatlıkla geçirir.
Redis bu işin pratik cevabı. INCR komutu atomik, EXPIRE ile pencere sonu otomatik temizleniyor, ZADD ve ZREMRANGEBYSCORE ile zaman damgalı kayıtları milisaniyede süpürebiliyorsunuz. Ayrıca tüm uygulama instance'ları aynı Redis'e bağlı olduğundan sayaç merkezi oluyor.
Hangi algoritma?
Genelde dört yaklaşım dolaşır:
- Fixed Window Counter:
rate:fixed:<user>:<dakika>gibi bir anahtaraINCRatarsınız, ilk artışta TTL verirsiniz. Çok basit ama pencere sınırında patlama riski var; kullanıcı 59. saniyede 100, 60. saniyede tekrar 100 isteği geçirirse bir saniye içinde iki katı yer. - Sliding Window Log: ZSET kullanır, her isteği zaman damgasıyla ekler, pencere dışını siler, kalanı sayar. En doğru yöntem ama her isteğin ayrı bir kayıt olması bellek maliyeti yaratıyor.
- Sliding Window Counter: iki pencere sayacının ağırlıklı toplamıyla yumuşatma yapar. Doğruluğu kabul edilebilir, maliyeti fixed window'a yakın.
- Token Bucket: kapasite ve saniyedeki yenilenme hızı tanımlarsınız; kullanıcı kovasında token varsa istek geçer. Burst trafiğine izin vermesi en güzel yanı.
Bence çoğu CRUD API için sliding window log fazlasıyla doğru bir yöntem; ama trafik patlamalı bir servis yazıyorsanız (örneğin webhook alan bir endpoint) token bucket'ı tercih ederim. Aşağıda ana örnek olarak sliding window log'u yazacağım, sonunda token bucket'a kısaca değineceğim.
Sliding window log uygulaması
redis-py ile minimum bir örnek yazalım:
import time
import uuid
import redis
r = redis.Redis(host='redis-cache', port=6379, decode_responses=True)
def is_allowed(user_id: str, limit: int = 100, window: float = 60.0) -> tuple[bool, int]:
key = f'rate:slw:{user_id}'
now = time.time()
cutoff = now - window
pipe = r.pipeline()
pipe.zremrangebyscore(key, 0, cutoff)
pipe.zadd(key, {f'{now}:{uuid.uuid4()}': now})
pipe.zcard(key)
pipe.expire(key, int(window) + 1)
_, _, count, _ = pipe.execute()
remaining = max(0, limit - count)
return count <= limit, remaining
Burada her isteği score=timestamp olarak ZSET'e yazıyoruz. Üyenin kendisinin tekil olması için sonuna bir uuid ekledik; aynı milisaniyede iki istek gelirse ZADD'in birini düşürmesini istemiyoruz. ZREMRANGEBYSCORE pencere dışında kalan eski kayıtları siliyor, ZCARD da kalan istek sayısını dönüyor.
Pipeline kullanmamızın sebebi de hem ağ gidiş-dönüşünü tek atışa indirmek hem de adımların ardışık olmasını sağlamak. Tam atomiklik istiyorsanız bir Lua script'e sarabilirsiniz; üretim için ben Lua'yı tercih ederim çünkü WATCH/MULTI döngüsünden sade duruyor.
FastAPI tarafında kullanmak
Bunu bir FastAPI dependency'si olarak bağlamak oldukça kolay:
from fastapi import FastAPI, HTTPException, Request, Depends
app = FastAPI()
def rate_limit(request: Request) -> None:
user_id = request.headers.get('X-User-Id', request.client.host)
allowed, remaining = is_allowed(user_id, limit=100, window=60.0)
if not allowed:
raise HTTPException(
status_code=429,
detail='Too Many Requests',
headers={'Retry-After': '60', 'X-RateLimit-Remaining': '0'},
)
request.state.rl_remaining = remaining
@app.get('/orders', dependencies=[Depends(rate_limit)])
async def list_orders():
return {'orders': []}
Limit aşıldığında 429 ile birlikte Retry-After header'ı dönüyoruz; kullanıcı (veya istemci kütüphanesi) ne kadar bekleyeceğini buradan öğrenir. Geçen isteklerde de X-RateLimit-Limit ve X-RateLimit-Remaining döndürmek nazik bir tercih, istemcilere kendilerini ayarlama şansı verir.
Token bucket'a kısa bir bakış
Token bucket'ı kabaca şöyle düşünebilirsiniz: kullanıcının bir kovası var, kapasitesi 100 token, saniyede 10 token doluyor. Her istek bir token harcıyor; kova boşsa istek reddediliyor. Burst tolere etmesi güzel ama atomik güncellemesi biraz daha çetrefilli.
Pratikte bunu Redis tarafında bir Lua script ile yazmak en sağlıklısı: kovadaki mevcut token sayısını ve son güncelleme zamanını tek bir hash'te tutarsınız, script geçen süreye göre yeniden doldurur ve atomik olarak token düşer. WATCH/MULTI ile de yapılabilir ama yarış durumunda retry maliyeti artıyor.
Sık karşılaşılan tuzaklar
- TTL vermemek: Anahtar Redis'te kalıcı olur, sayaç sıfırlanmaz, kullanıcı sonsuza kadar 429 görür. ZSET'te de
EXPIREmutlaka olsun. - IP'yi tek anahtar yapmak: NAT arkasındaki yüzlerce kullanıcı tek IP ile gelir. Mümkünse kimlik doğrulamasından gelen
user_id'ye dayanın; yoksa IP + token gibi bir bileşim kullanın. - Sayacı uygulama belleğinde tutmak: Birden fazla worker varsa her biri farklı sayaç tutar; toplam görünmez. Tüm sayaç merkezi (Redis'te) olmalı.
- Lua script'i her istekte göndermek:
SCRIPT LOADile bir kez yükleyipEVALSHAile çağırın; binlerce byte'lık script'i her seferinde göndermek anlamsız.
Kapanış
Bu yazıda Redis ile sliding window log tabanlı bir hız sınırlayıcıyı Python tarafında nasıl kurduğumuzu gördük, FastAPI tarafına bağladık ve token bucket'a kısaca değindik. Şahsi kanaatim, çoğu API için sliding window log fazlasıyla yetiyor; gerçekten burst trafiği almanız gereken bir yer varsa token bucket'ı Lua ile yazıp dosyalayın, bir daha açmazsınız. Umarım faydalı olur, bir sonraki yazıda görüşmek üzere.
