๐ Rate Limiting: Token Bucket, Sliding Window, Versi Terdistribusi
๐ฏ Tujuan Belajar
Setelah sesi ini, kamu bisa:
- Memilih algoritma rate limiter berdasarkan profil burst yang diinginkan, bukan berdasarkan "yang lagi tren".
- Tahu kenapa rate limiter yang benar di satu proses jadi salah begitu ada >1 instance.
- Bisa membaca trade-off akurasi vs latensi vs biaya Redis dan memutuskan batas toleransi drift-nya.
- Tahu di layer mana rate limit dipasang (edge / gateway / app / DB) dan apa yang bocor di tiap layer.
๐ง Penjelasan Inti
Rate limiter menjawab satu pertanyaan: "boleh request ini lewat sekarang?" berdasarkan state yang dihitung dari request sebelumnya.
Kenapa ada:
- Proteksi kapasitas โ mencegah satu klien menghabiskan resource bersama (CPU, koneksi DB, kuota upstream berbayar).
- Fairness โ distribusi kapasitas antar tenant/user.
- Kontrol biaya โ API berbayar per-call (LLM, maps, SMS) wajib di-throttle.
- Pertahanan abuse โ brute force, credential stuffing, scraping.
- Stabilitas sistem โ membatasi arrival rate agar antrean internal tak meledak (lihat: backpressure vs load shedding โ beda hal, dibahas di bagian trade-off).
Yang sering dilupakan: rate limiting itu bukan load shedding dan bukan concurrency limiting. Rate limit membatasi laju kedatangan; concurrency limit membatasi yang sedang diproses. Sistem bisa punya RPS rendah tapi tiap request butuh 30 detik โ tetap kolaps. Dua-duanya dibutuhkan.
โ๏ธ Cara Kerja Internal
1. Fixed Window Counter
Bagi waktu jadi window tetap (mis. per menit). Counter per window, reset saat window berganti.
key = "rl:{user}:{floor(now/60)}"
count = INCR key; if count == 1: EXPIRE key 60
allow = count <= limit
Masalahnya klasik: boundary burst. Limit 100/menit. Klien kirim 100 request di detik ke-59, lalu 100 lagi di detik ke-61 (window baru). Dalam 2 detik ia lolos 200 request โ 2ร limit. Burst ini nyata di produksi dan jadi celah abuse.
2. Sliding Window Log
Simpan timestamp tiap request, buang yang lebih tua dari window, hitung sisanya.
- Akurasi: eksak.
- Biaya memori: O(limit) per key. Limit 10.000/menit ร 1 juta user = mati.
- Cocok untuk limit kecil dan presisi tinggi (mis. 5 login attempt / 15 menit).
3. Sliding Window Counter (weighted / hybrid)
Interpolasi antara dua fixed window:
prev_count, cur_count
elapsed_ratio = (now - window_start) / window_size
estimate = cur_count + prev_count * (1 - elapsed_ratio)
allow = estimate < limit
- Memori O(1), akurasi mendekati sliding log (error maksimal sebesar burst di boundary, umumnya <5% untuk trafik merata).
- Ini default pilihan realistis untuk API publik. Perhatikan: hasilnya estimasi, bukan jaminan. Kalau limit-mu terkait uang atau SLA keras, jangan pakai ini sendirian.
4. Token Bucket
Bucket berisi token, terisi dengan laju r token/detik hingga kapasitas b. Tiap request mengambil 1 token (atau n untuk biaya berbobot).
state: (tokens, last_refill_ts)
on_request(now, cost):
tokens = min(b, tokens + (now - last_refill_ts) * r)
last_refill_ts = now
if tokens >= cost: tokens -= cost; allow
else: deny (retry_after = (cost - tokens) / r)
Karakteristik: mengizinkan burst hingga b, lalu melaju konstan r. Ini yang biasanya diinginkan: pengguna boleh "nembak" beberapa request cepat, tapi laju rata-rata terjaga.
Lazy refill (menghitung saat request datang, bukan timer) adalah implementasi standar โ tidak ada background thread, tidak ada timer drift di level bucket.
5. Leaky Bucket
Antrean dengan laju keluar konstan. Input melimpah โ dibuang (atau diantrekan). Output rate sempurna konstan, tapi tidak ada burst allowance. Dipakai saat downstream butuh laju sangat rata (mis. kirim ke sistem legacy, atau shaping traffic ke pihak ketiga). Token bucket = "izin burst"; leaky bucket = "paksa rata".
6. GCRA (Generic Cell Rate Algorithm)
Versi hemat memori dari leaky bucket. State-nya satu angka: TAT (theoretical arrival time).
tat = max(tat, now) + emission_interval
if tat - burst_tolerance > now: deny, retry_after = tat - burst_tolerance - now
Satu-satunya state adalah satu float/int per key. Ini yang dipakai di banyak rate limiter modern (mis. implementasi token-bucket berbasis Redis yang menyimpan dua field, atau GCRA yang menyimpan satu). Trade-off: kamu dapat burst tolerance dan rate, tapi tidak bisa menanyakan "berapa token tersisa" secara natural (bisa dihitung, tapi tidak intuitif). Kalau butuh header X-RateLimit-Remaining yang akurat untuk semua algoritma, GCRA sedikit lebih repot.
๐๏ธ Kompleksitas & State
| Algoritma | State per key | Akurasi | Burst | Cocok untuk |
|---|---|---|---|---|
| Fixed window | 1 int | buruk di boundary | 2ร limit | internal, low stakes, murah |
| Sliding log | O(limit) | eksak | tidak ada | limit kecil presisi tinggi (auth) |
| Sliding window counter | 2 int | near-exact | ~1ร limit | API publik default |
| Token bucket | 2 field | eksak | b |
API umum, quota berbayar |
| Leaky bucket | 1 timestamp / queue | eksak | tidak ada | shaping ke downstream sensitif |
| GCRA | 1 int | eksak | burst_tolerance |
high-throughput, hemat memori |
โ๏ธ Versi Terdistribusi โ Ini Bagian yang Sebenarnya Sulit
Rate limiter in-process (mis. golang.org/x/time/rate, Bucket4j lokal, express-rate-limit default memory store) hanya benar untuk satu proses. Dengan N instance, limit efektif = N ร limit. Kalau kamu pakai autoscaling, limit-mu berubah-ubah mengikuti jumlah pod โ bug yang sangat sulit direproduksi di staging.
Opsi state bersama, dengan konsekuensinya:
(a) Redis INCR + EXPIRE (fixed window) โ atomik, satu round-trip, tapi boundary burst tetap ada dan INCR+EXPIRE bukan operasi atomik gabungan (gunakan Lua atau SET ... EX dengan pola yang benar; race pada EXPIRE bisa membuat key tanpa TTL โ memory leak yang tidak pernah mati).
(b) Redis + Lua script (token bucket / GCRA) โ atomik, satu RTT, state kecil. Ini praktik standar. Biayanya: setiap request butuh round-trip ke Redis sebelum bisa diputuskan.
(c) Redis Cluster โ key harus di satu slot. Rate limit per user โ key per user โ tersebar. Kalau kamu pakai hash tag {user_id}, key terkait user itu menempel di satu shard โ aman, tapi hot user bisa jadi hot shard. Kalau pakai multi-key (mis. limit global + limit per user dalam satu script), wajib hash tag yang sama, dan itu memaksa keduanya di shard yang sama โ kompromi antara atomisitas dan distribusi.
(d) Local + async sync (approximate) โ tiap instance punya bucket lokal yang diisi dari rate r/N, dan secara periodik melakukan reconciliation (baca/tulis ke Redis tiap X ms). Trade-off: latensi nol (tidak ada round-trip per request), tapi bisa over-admit sampai N ร b selama jeda sinkronisasi. Untuk limit yang tidak terkait uang, ini sering paling pragmatis. Untuk quota berbayar, jangan.
(e) Edge/CDN (Cloudflare, API Gateway, Nginx limit_req) โ dilakukan sebelum request menyentuh aplikasi. limit_req Nginx = leaky bucket. Sangat murah dan melindungi lapisan di bawahnya, tapi granularitasnya kasar (IP, path) dan tidak tahu identitas user yang di-autentikasi aplikasi.
Poin penting: lapisan-lapisan ini tidak saling menggantikan. Pola produksi biasanya: edge limit per IP (anti-abuse kasar) + gateway/app limit per API key/user (quota + fairness) + concurrency limit per backend (proteksi resource). Tiap lapisan punya tujuan berbeda.
Fail-open vs fail-closed
Kalau Redis mati, apa yang terjadi? Dua pilihan, dan ini keputusan bisnis, bukan teknis:
- Fail-open โ tolak rate limiting, izinkan request. Ketersediaan terjaga, tapi proteksi hilang (bisa jadi insiden biaya).
- Fail-closed โ tolak semua. Proteksi terjaga, tapi aplikasi mati meski backend sehat. Untuk auth/anti-brute-force, fail-closed masuk akal. Untuk read endpoint publik, fail-open lebih benar.
Praktik yang lebih baik: fallback ke local bucket yang lebih ketat saat Redis unreachable (mis. limit lokal = limit global / N, dengan sedikit margin). Degradasi bertingkat, bukan biner.
โ๏ธ Trade-off & Kapan JANGAN Dipakai
- Jangan pakai rate limiting untuk melindungi sistem yang lambat. Kalau tiap request butuh 2 detik karena query buruk, rate limit 10 rps hanya menyembunyikan masalah dan membuat latensi p99 naik. Perbaiki query-nya. Rate limiter bukan penutup bottleneck.
- Jangan rate-limit di app layer kalau masalahnya bisa diselesaikan di edge. Round-trip Redis per request untuk menolak traffic yang jelas-jelas abusive = pemborosan. Blok di CDN dulu.
- Jangan pakai fixed window untuk apa pun yang berhubungan dengan uang atau SLA. Boundary burst = kebocoran nyata, bukan teoretis โ 2ร limit terjadi setiap menit secara deterministik, bisa diprediksi penyerang.
- Jangan pakai sliding log untuk limit besar.
limit=10000ร 1 juta user = memori Redis yang tidak masuk akal. - Jangan taruh rate limit sebagai satu-satunya proteksi DB. Rate limit per API key tidak melindungi dari 10.000 user sah yang masing-masing mengirim 1 rps. Butuh juga concurrency limit + connection pool limit + query timeout. Ini tiga kontrol berbeda.
- Hindari "global limit per key" tanpa thinking tentang distribusi key. Satu tenant enterprise dengan 5.000 rps bisa menghabiskan limit global dan memblokir semua orang. Biasanya yang benar: limit per-tenant plus limit global yang jauh lebih longgar.
Retry-Afteradalah kontrak. Kalau kamu kirim 429 tanpaRetry-After, klien akan retry acak dan memperburuk keadaan (retry storm). Sebaliknya, klien yang menghormatiRetry-Afterbutuh server yang tidak mengirimnya salah. KirimRetry-Afterdari state bucket ((cost - tokens)/r), bukan angka hardcoded.
Jangan pakai rate limit untuk masalah concurrency
Kalau downstream bisa melayani 50 request paralel dengan latensi 100 ms, rate limit yang tepat adalah ~500 rps asumsi latensi stabil. Begitu latensi naik ke 1 detik (slow query, GC pause), 500 rps = 500 request paralel > kapasitas 50 โ antrean meledak, lalu timeout, lalu retry, lalu kolaps. Di sini yang dibutuhkan concurrency limit (semaphore / queued requests limit, mis. max_inflight), bukan rate limit. Rate limit dan concurrency limit adalah dua alat berbeda.
๐ญ Contoh Produksi & Failure Mode
Boundary burst fixed window โ nyata di banyak API. Limit "1000/jam" yang diimplementasi dengan counter per jam bisa dilalui 2000 request dalam 2 detik di pergantian jam. Solusi: sliding window atau token bucket.
Key tanpa TTL โ INCR berhasil, EXPIRE gagal (proses mati di antara dua perintah, atau Lua script salah). Key tumbuh tanpa batas โ Redis kehabisan memori โ eviction โ rate limit state hilang random โ semua user tiba-tiba boleh lagi, atau semuanya diblok. Wajib pakai atomic script atau SET ... EX NX + INCR dengan pola yang menjamin TTL.
Autoscaling mengubah limit โ limiter lokal per pod. Trafik naik โ autoscaler naik ke 10 pod โ limit efektif naik 10ร. Penyerang yang punya kapasitas menghasilkan lonjakan justru mendapat lebih banyak kuota. Ini adalah alasan paling umum kenapa rate limiter "tidak jalan" di produksi.
Redis sebagai single point of failure โ semua traffic butuh Redis sebelum bisa diputuskan. Redis latency 5 ms โ setiap request +5 ms. Redis down โ seluruh API down (fail-closed tanpa fallback). Mitigasi: local caching/approximate mode, Redis clustering + replica read, atau bataskan rate limit ke subset request (mis. 1 dari 8 request dicek, sisanya diadmit) โ probabilistic admission, teknik yang mengkompromikan akurasi demi ketersediaan.
Hot key di Redis Cluster โ satu user API-key besar membuat satu shard jadi bottleneck seluruh cluster. Mitigasi: sharding key per key (key:{id}:shard dengan token pool terbagi), atau pindahkan limit user besar ke jalur khusus.
429 tanpa Retry-After + klien retry eksponensial tanpa jitter โ ribuan klien retry serentak di waktu yang sama; beban setelah 429 bisa lebih tinggi daripada sebelum. Rate limit memperparah insiden karena klien yang salah konfigurasi (ยฑ retry storm) adalah pola yang berulang di dunia nyata.
Limit per IP di belakang NAT/proxy โ 500 pengguna kantor berbagi satu IP publik; edge rate limit per IP memblokir semua orang karena satu pengguna scrap. Perlu X-Forwarded-For yang dipercaya dengan hati-hati (bisa dipalsukan) dan/atau limit per identitas aplikasi, bukan per IP.
โ ๏ธ Miskonsepsi Umum
- "Rate limit = mencegah serangan DDoS." Tidak. Rate limit melindungi terhadap abuse berkapasitas terbatas. DDoS volumetrik (ratusan Gbps) harus di-handle di network/edge, tidak oleh aplikasi.
- "Token bucket jarang menolak, jadi lebih baik." Token bucket mengizinkan burst hingga
bโ untuk downstream yang sensitif terhadap lonjakan mendadak, ini justru kelemahan. Pilih algoritma berdasarkan bentuk traffic yang kau inginkan, bukan yang paling "ramah". - "Satu rate limiter cukup untuk semua." Satu limiter untuk semua endpoint sering salah: endpoint baca murah vs endpoint tulis mahal vs endpoint auth (perlu ketat) vs endpoint webhook (perlu longgar). Biasanya beberapa limiter dengan budget berbeda, dihitung dari biaya resource masing-masing.
- "
429cukup tanpa detail."Retry-After,X-RateLimit-Limit/Remaining/Resetadalah bagian dari kontrak API. Tanpa itu klien tidak bisa berperilaku benar. Tapi ingat: headerRemainingdari algoritma berbasis estimasi (sliding counter) hanyalah perkiraan โ jangan janjikan presisi di dokumentasi. - "Redis Lua selalu cepat." Script dengan banyak key atau logic berat memblokir Redis single-threaded. Rate limit script harus O(1) atau O(k) kecil, dan hindari
EVALdengan argumen besar. - "Rate limit di reverse proxy sudah melindungi aplikasi." Hanya jika proxy melihat identitas yang benar dan traffic memang lewat proxy. Traffic internal service-to-service, job background, dan jalur admin sering bypass limiter. Ini sumber insiden yang konsisten: limiter menghalangi user, tapi job internal yang buggy tetap menghantam DB sampai mati.
โ Cek Pemahaman
- Kamu punya limit 1000 req/menit dengan fixed window. Penyerang mengirim 1000 request di detik ke-59 tiap menit. Berapa request yang ia loloskan per menit efektif, dan apa perubahan minimal yang menghentikannya?
- API-mu berjalan di 8 pod dengan autoscaling 2โ20 pod. Rate limiter token bucket lokal dengan
r = 100/sper pod. Limit yang kamu janjikan ke pelanggan adalah "100 req/s per API key". Apa yang salah, dan dua cara memperbaikinya dengan trade-off masing-masing? - Downstream hanya tahan 50 request paralel; rata-rata latensi 200 ms. Kamu diberi pilihan: (a) rate limit 250 rps, (b) concurrency limit 50. Kapan (a) cukup, kapan (b) wajib? Jelaskan skenario di mana (a) gagal tapi (b) berhasil.
๐ Poin Kunci
- Pilih algoritma dari profil burst yang diinginkan: token bucket (burst
b), leaky bucket/GCRA (rata), sliding counter (near-exact, murah), sliding log (eksak, mahal). - Fixed window punya boundary burst deterministik โ jangan untuk uang/SLA.
- Rate limit satu proses salah begitu ada >1 instance. State harus dibagi atau diterima sebagai approximate.
- Redis Lua = standar; perhatikan TTL atomisitas, hash tag cluster, dan hot key.
- Local+sync = latensi bagus, over-admit; cocok untuk limit non-kritis, tidak cocok untuk quota berbayar.
- Fail-open vs fail-closed adalah keputusan bisnis per-endpoint; fallback ke limit lokal ketat lebih baik dari biner.
- Rate limit โ concurrency limit โ load shedding. Sistem produksi butuh ketiganya.
- Selalu kirim
Retry-Afteryang dihitung dari state; klien tanpa jitter retry membuat 429 memperburuk insiden.
โก๏ธ Eksplorasi Lanjut
- Concurrency limiting & adaptive limit (Netflix concurrency-limits, algoritma AIMD/Gradient2) โ bagaimana menyesuaikan limit terhadap latensi aktual, bukan angka statis.
- Load shedding vs rate limiting vs admission control โ bedanya dan kapan tiap lapisan dipasang.
- Distributed sliding window di database (mis. tabel
rate_limitsdengan UPSERT) โ kapan ini lebih masuk akal daripada Redis (mis. saat butuh audit dan durability, bukan kecepatan). - Rate limiting untuk LLM/token budget โ limit berbasis biaya (token per menit), bukan request per menit; weighted token bucket dengan cost per call tidak seragam.
- Backpressure di streaming (gRPC flow control, HTTP/2 window) โ batas laju yang dinegosiasikan, bukan dipaksakan sepihak.