Rate limiting
Tanpa rate limit, satu skrip bisa menghabiskan CPU-mu lewat endpoint login, mengosongkan kuota API pihak ketiga, atau membuat tagihan AWS-mu meledak dalam semalam.
Intisari
golang.org/x/time/ratemenyediakan token bucket โ laju rata-rata plus toleransi lonjakan.- Limiter di memori tidak cukup saat ada beberapa container: batas efektifnya jadi N kali lipat.
- Untuk batas bersama, pakai Redis; untuk pertahanan lapisan luar, pakai AWS WAF (Fase 10).
- Rate limit dipasang sebelum autentikasi, supaya verifikasi mahal tidak ikut dibanjiri.
- Balas
429dengan headerRetry-AfterdanRateLimit-*.
Token bucket
Ember berisi token, terisi ulang r token/detik, kapasitas b.
Setiap permintaan mengambil satu token; ember kosong = ditolak.
rate.NewLimiter(10, 20)
โ rata-rata 10 permintaan/detik
โ boleh melonjak sampai 20 sekaligus setelah periode tenang
import "golang.org/x/time/rate"
l := rate.NewLimiter(rate.Limit(10), 20)
if !l.Allow() { // tidak memblokir; langsung menjawab
http.Error(w, "terlalu banyak permintaan", http.StatusTooManyRequests)
return
}
// Varian lain
_ = l.Wait(ctx) // menunggu giliran (untuk klien keluar, bukan server)
res := l.Reserve() // reservasi dengan waktu tunggu yang bisa dibaca
Di sisi server pakai Allow(), bukan Wait(). Wait
menahan goroutine sampai token tersedia โ artinya banjir permintaan berubah jadi ribuan goroutine yang
menunggu, dan kamu justru memperbesar dampak serangannya. Wait tepat untuk sisi
klien: menghormati rate limit API yang kamu panggil.
Per-kunci, dengan pembersihan
type Pembatas struct {
mu sync.Mutex
klien map[string]*entri
r rate.Limit
b int
}
type entri struct {
l *rate.Limiter
terakhir time.Time
}
func (p *Pembatas) Izinkan(kunci string) bool {
p.mu.Lock()
defer p.mu.Unlock()
e, ada := p.klien[kunci]
if !ada {
e = &entri{l: rate.NewLimiter(p.r, p.b)}
p.klien[kunci] = e
}
e.terakhir = time.Now()
return e.l.Allow()
}
// WAJIB: tanpa pembersihan, map ini tumbuh selamanya โ dan itu
// kebocoran memori yang dikendalikan penyerang.
func (p *Pembatas) bersihkan(ctx context.Context) {
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
p.mu.Lock()
for k, e := range p.klien {
if time.Since(e.terakhir) > 10*time.Minute {
delete(p.klien, k)
}
}
p.mu.Unlock()
}
}
}
Kunci: dari apa membatasinya
| Kunci | Untuk | Catatan |
|---|---|---|
| ID pengguna | Endpoint terautentikasi | Paling akurat |
| Kunci API | API publik | Batas berbeda per paket langganan |
| IP | Endpoint anonim | Satu kantor / operator seluler berbagi IP |
| Email + IP | Login | Mencegah credential stuffing pada satu akun |
| Global per endpoint | Endpoint yang sangat mahal | Pengaman terakhir |
// Mendapatkan IP asli di belakang ALB/CloudFront.
// X-Forwarded-For BISA DIPALSUKAN kalau kamu percaya seluruh isinya.
func ipKlien(r *http.Request, jumlahProxy int) string {
xff := r.Header.Get("X-Forwarded-For")
if xff == "" {
host, _, _ := net.SplitHostPort(r.RemoteAddr)
return host
}
bagian := strings.Split(xff, ",")
// Hitung dari KANAN: entri paling kanan ditambahkan proxy tepercayamu.
// Entri paling kiri bisa saja diketik sendiri oleh klien.
i := len(bagian) - jumlahProxy
if i < 0 {
i = 0
}
return strings.TrimSpace(bagian[i])
}
Mengambil elemen pertama X-Forwarded-For adalah kesalahan yang membatalkan seluruh
rate limit-mu. Penyerang cukup mengirim X-Forwarded-For: 1.2.3.4 yang berbeda setiap
permintaan, dan setiap permintaan dianggap datang dari klien baru. Hitung dari kanan sebanyak proxy
yang benar-benar kamu punya.
Batas yang wajar
| Endpoint | Batas | Kunci |
|---|---|---|
| Login | 5 / menit | email + IP |
| Lupa sandi, pendaftaran | 3 / menit | IP |
| Kirim OTP / SMS | 3 / jam | nomor telepon โ ini biaya langsung |
| Pencarian | 30 / menit | pengguna atau IP |
| API baca | 60โ600 / menit | kunci API |
| API tulis | 10โ60 / menit | kunci API |
| Unggah berkas | 10 / jam | pengguna |
Balasan yang benar
func tolak(w http.ResponseWriter, sisa time.Duration, batas int) {
w.Header().Set("Retry-After", strconv.Itoa(int(sisa.Seconds()+1)))
w.Header().Set("RateLimit-Limit", strconv.Itoa(batas))
w.Header().Set("RateLimit-Remaining", "0")
w.Header().Set("RateLimit-Reset", strconv.Itoa(int(sisa.Seconds()+1)))
http.Error(w, "terlalu banyak permintaan", http.StatusTooManyRequests)
}
Batas bersama antar container
// Limiter di memori ร 10 task = batas efektif 10 kali lipat.
// Untuk batas yang benar-benar global, hitungannya harus di Redis.
func (p *PembatasRedis) Izinkan(ctx context.Context, kunci string,
batas int, jendela time.Duration) (bool, error) {
k := fmt.Sprintf("rl:%s:%d", kunci, time.Now().Unix()/int64(jendela.Seconds()))
n, err := p.rdb.Incr(ctx, k).Result()
if err != nil {
return true, err // Redis mati โ JANGAN kunci semua orang
}
if n == 1 {
p.rdb.Expire(ctx, k, jendela)
}
return n <= int64(batas), nil
}
Perhatikan return true, err. Saat Redis tidak tersedia, pilihannya adalah menolak
semua permintaan atau meloloskan semuanya. Untuk rate limit, meloloskan hampir selalu benar: gangguan
cache tidak boleh berubah jadi pemadaman total. Untuk otorisasi, jawabannya kebalikannya.
Berlapis
| Lapisan | Menangani |
|---|---|
| CloudFront + AWS WAF | Volume besar, sebelum menyentuh aplikasi โ termurah |
| ALB | Batas koneksi |
| Middleware aplikasi | Aturan per pengguna dan per endpoint |
| Batas berbasis Redis | Kuota yang benar-benar global |
Batas di sisi klien (rate.Wait) | Menghormati kuota API pihak ketiga |
Latihan: pasang rate limit 5/menit pada endpoint login dengan kunci email+IP. Buktikan dengan
for i in $(seq 10); do curl ...; done bahwa permintaan keenam mendapat 429 beserta
Retry-After. Lalu jalankan dua instans aplikasimu dan buktikan batas efektifnya jadi
sepuluh โ itu alasan Redis dibutuhkan.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.