Herkese açık bir API yayınladığınız anda kaçınılmaz bir soruyla karşılaşırsınız: Tek bir istemci saniyede binlerce istek atarsa ne olacak? Rate limiting (hız sınırlama), bu soruya verilen en temiz cevaptır — API’nizi kötüye kullanımdan, yanlışlıkla oluşan sonsuz döngülerden ve ani yük altında çökmekten korur. Bu yazıda en yaygın algoritma olan token bucket’ı, Go’nun standart dışı ama fiili standart paketi golang.org/x/time/rate ile pratikte nasıl uygulayacağınızı ve tek sunucudan dağıtık sınırlamaya geçerken nelerin değiştiğini adım adım göreceğiz.
Neden Token Bucket?
Birkaç yaygın algoritma vardır ve token bucket, esnekliği sayesinde en çok tercih edilenidir:
- Sabit pencere (fixed window): “Dakikada 100 istek.” Basit ama pencere sınırında iki katı yüke izin verebilir (99. saniyede 100, 61. saniyede 100 daha).
- Kayan pencere (sliding window): Daha adil ama tutması daha maliyetli.
- Token bucket: Kapasitesi
bolan bir kova, saniyederhızında dolar. Her istek bir token harcar; kova boşsa istek reddedilir. Kova doluyken gelen ani yüke (burst) izin verir, ama uzun vadede ortalamayırile sınırlar. Çoğu API için istenen davranış tam olarak budur.
En Basit Hâli: Tek Bir Limiter
golang.org/x/time/rate paketi token bucket’ı doğrudan sunar. Tek bir global limiter şöyle kurulur:
import "golang.org/x/time/rate"
// Saniyede 10 token üret, kova kapasitesi 30 (30'a kadar burst).
limiter := rate.NewLimiter(10, 30)
if limiter.Allow() {
// isteği işle
} else {
// reddet: 429 Too Many Requests
}
Allow() anlık ve bloklamayan bir kontroldür. Eğer isteği reddetmek yerine sıraya sokup beklemek isterseniz limiter.Wait(ctx) kullanabilirsiniz; bu, bir token boşalana kadar (veya context iptal olana kadar) bloke olur.
Gerçek İhtiyaç: İstemci Başına Sınırlama
Tek bir global limiter tüm kullanıcıları aynı kovaya koyar — bir kullanıcı diğerlerinin kotasını tüketebilir. Üretimde genelde IP veya API anahtarı başına sınırlama istersiniz. Bunun için istemci başına bir limiter haritası tutarız:
type IPRateLimiter struct {
mu sync.Mutex
limiters map[string]*clientLimiter
r rate.Limit
b int
}
type clientLimiter struct {
limiter *rate.Limiter
lastSeen time.Time
}
func NewIPRateLimiter(r rate.Limit, b int) *IPRateLimiter {
return &IPRateLimiter{
limiters: make(map[string]*clientLimiter),
r: r,
b: b,
}
}
func (i *IPRateLimiter) getLimiter(ip string) *rate.Limiter {
i.mu.Lock()
defer i.mu.Unlock()
cl, ok := i.limiters[ip]
if !ok {
cl = &clientLimiter{limiter: rate.NewLimiter(i.r, i.b)}
i.limiters[ip] = cl
}
cl.lastSeen = time.Now()
return cl.limiter
}
sync.Mutex kullanımı burada kritik: birden çok goroutine aynı anda map’e yazabilir. Kilitsiz erişim data race’e yol açar — bu ve benzeri eşzamanlılık tuzaklarını sık yapılan hatalar yazımda ele almıştım.
Belleği Koruyun: Idle Girdileri Temizleyin
Yukarıdaki map, her yeni IP için bir girdi ekler ama hiçbirini silmez. Yüksek trafikte bu, yavaş bir bellek sızıntısıdır. Periyodik çalışan bir temizleyici goroutine ile uzun süredir görülmeyen IP’leri atın:
func (i *IPRateLimiter) cleanup(maxIdle time.Duration) {
for range time.Tick(time.Minute) {
i.mu.Lock()
for ip, cl := range i.limiters {
if time.Since(cl.lastSeen) > maxIdle {
delete(i.limiters, ip)
}
}
i.mu.Unlock()
}
}
Bunu uygulama başlarken go limiter.cleanup(10 * time.Minute) ile başlatmanız yeterli. Kontrolsüz goroutine başlatmanın da bir maliyeti olduğunu unutmayın — bu tek, ömür boyu çalışan bir goroutine olduğu için burada güvenlidir.
HTTP Middleware: 429 ve Retry-After
Şimdi hepsini standart bir net/http middleware’ine saralım. Reddedilen isteklere doğru durum kodunu (429 Too Many Requests) ve istemciye ne zaman tekrar deneyeceğini söyleyen Retry-After başlığını döndürmek önemlidir:
func RateLimit(limiter *IPRateLimiter, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
if !limiter.getLimiter(ip).Allow() {
w.Header().Set("Retry-After", "1")
http.Error(w, "İstek limiti aşıldı", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
Bir uyarı: r.RemoteAddr bir ters proxy (Nginx, Cloudflare) arkasındaysa proxy’nin IP’sini verir. Gerçek istemci IP’si için X-Forwarded-For / X-Real-IP başlıklarını yalnızca güvendiğiniz proxy’den geliyorsa dikkatle okuyun; aksi halde istemci bu başlığı taklit edip sınırı atlatabilir.
Ölçek Sınırı: Tek Instance’tan Dağıtığa
Şu ana kadar kurduğumuz her şey in-memory’dir ve tek bir instance için mükemmel çalışır — hızlı, bağımlılıksız, ağ gidiş-dönüşü yok. Ama uygulamanızı bir yük dengeleyici arkasında birden çok instance ile çalıştırdığınız an mantık bozulur: her instance kendi kovasını sayar. İki instance’ınız varsa, “saniyede 10” limiti gerçekte “saniyede 20”ye çıkar.
Global bir limit için sayacın paylaşılan bir yerde tutulması gerekir — pratikte Redis. Redis üzerinde token bucket’ı atomik tutmak için genelde bir Lua script kullanılır (birden çok komutun araya girmeden çalışması için). go-redis/redis_rate gibi hazır kütüphaneler bu deseni kutudan sunar.
Ama buradaki asıl karar teknik değil, mimaridir: Dağıtık rate limiting’i ihtiyaç doğduğunda ekleyin. Tek instance çalışıyorsanız in-memory çözüm hem daha hızlı hem daha basittir. Bu “önce basit, gerektiğinde dağıtık” yaklaşımını tek sunucuda ölçekleme yazımda ayrıntılı savundum.
Özet
Rate limiting, herkese açık bir API’nin pazarlık konusu olmayan bir parçasıdır. Token bucket, burst’e izin verirken ortalamayı sınırladığı için çoğu senaryonun doğru cevabıdır; Go’da golang.org/x/time/rate bunu hazır sunar. Üretim için üç şeyi ekleyin: istemci başına limiter, idle girdileri temizleyen bir cleanup, ve 429 + Retry-After döndüren bir middleware. Dağıtık sınırlamaya ise ancak yatay ölçeğe geçtiğinizde, ölçülü bir ihtiyaç olarak yönelin.
Bu deseni Clean Architecture içinde doğru katmana yerleştirmek için dış API katmanı yazıma, net/http ve time paketlerini derinlemesine kullanmak için standart kütüphane yazıma göz atabilirsiniz.