โ† Semua pembelajaran / Go Nol โ†’ Enterprise
Fase 6 ยท Keamanan

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.

Sumber asli pkg.go.dev Resmi Rangkuman ~7 menit baca

Intisari

  • golang.org/x/time/rate menyediakan 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 429 dengan header Retry-After dan RateLimit-*.

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

KunciUntukCatatan
ID penggunaEndpoint terautentikasiPaling akurat
Kunci APIAPI publikBatas berbeda per paket langganan
IPEndpoint anonimSatu kantor / operator seluler berbagi IP
Email + IPLoginMencegah credential stuffing pada satu akun
Global per endpointEndpoint yang sangat mahalPengaman 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

EndpointBatasKunci
Login5 / menitemail + IP
Lupa sandi, pendaftaran3 / menitIP
Kirim OTP / SMS3 / jamnomor telepon โ€” ini biaya langsung
Pencarian30 / menitpengguna atau IP
API baca60โ€“600 / menitkunci API
API tulis10โ€“60 / menitkunci API
Unggah berkas10 / jampengguna

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

LapisanMenangani
CloudFront + AWS WAFVolume besar, sebelum menyentuh aplikasi โ€” termurah
ALBBatas koneksi
Middleware aplikasiAturan per pengguna dan per endpoint
Batas berbasis RedisKuota 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.