API yang sehat punya musuh yang bukan hacker: skrip yang salah mengirim sepuluh ribu permintaan per detik, klien yang me-retry tanpa jeda, atau satu pelanggan yang menghabiskan seluruh kapasitas server. Rate limiting membatasi laju permintaan per pemanggil agar kegagalan seperti itu tidak menjalar. Kita akan menerapkan algoritma token bucket secara manual, menentukan batas per API key, mengirim status 429 beserta header Retry-After, dan menyuntik waktu lewat interface agar perilaku limiter teruji secara deterministik.

Token Bucket: Burst dan Laju Berkelanjutan #

Token bucket membayangkan ember berisi token: setiap permintaan menghabiskan satu token, dan ember terisi ulang dengan laju tetap. Dua angka mengendalikan perilakunya — kapasitas menentukan burst: berapa permintaan boleh lewat sekaligus saat ember penuh; pengisian menentukan laju berkelanjutan: rata-rata permintaan per detik dalam jangka panjang. Konfigurasi kapasitas 3, pengisian 1/detik mengizinkan tiga permintaan beruntun seketika, tetapi rata-rata hanya satu per detik bila terus-menerus. Kombinasi ini lazim: burst menoleransi lonjakan wajar, laju berkelanjutan menahan rata-rata jangka panjang.

Menyuntik Waktu demi Determinisme #

Algoritma laju bergantung pada waktu, sementara waktu sungguhan berubah antar eksekusi — output pengujian tidak stabil. Solusinya: sembunyikan sumber waktu di balik interface PembacaJam dengan satu metode Sekarang(). Produksi mengisi field jam dengan pembungkus time.Now; pengujian mengisinya dengan jamBeku yang berangkat dari satu waktu tetap dan hanya maju ketika Maju dipanggil. Program di bawah memakai jam beku: lima permintaan “berturut-turut” terjadi pada satu momen, dan satu detik berlalu hanya karena diperintahkan.

429 dan Retry-After #

Permintaan yang menemukan ember kosong dijawab status 429 Too Many Requests. Responsnya semestinya disertai header Retry-After berisi jumlah detik hingga satu token tersedia kembali, dihitung dari defisit token dibagi laju pengisian, dibulatkan ke atas. Klien yang menghormatinya menunda percobaan ulang selama yang diperlukan, bukan membanjiri server dengan retry.

Limit per Kunci dan Keadilan #

Batas yang berguna harus per identitas, bukan global: limiter menyimpan satu bucket per API key di dalam map. Pemanggil rakus hanya menguras bucket miliknya — kunci-siti tetap penuh ketika kunci-budi terblokir. Dua kebijakan umum: API key (akurat, hanya menjangkau pemanggil terdaftar) dan alamat IP (menjangkau semua, tetapi seluruh kantor di belakang satu NAT berbagi satu bucket). Tanpa pembedaan, satu pemanggil bermasalah memblokir seluruh API — kebalikan dari keadilan.

Program: Burst, Isi Ulang, dan Isolasi Kunci #

Program ratelimit-demo.go melayani GET /laporan di belakang limiter dengan kebijakan burst 3 dan laju 1/detik. Delapan permintaan terbagi empat bagian: lima tanpa jeda, satu setelah jam maju satu detik, satu dari kunci lain, satu tanpa kunci.

go
package main

import (
	"encoding/json"
	"fmt"
	"math"
	"net/http"
	"net/http/httptest"
	"strconv"
	"time"
)

// PembacaJam adalah sumber waktu yang bisa disuntik. Produksi memakai
// pembungkus tipis di atas time.Now; demo memakai jam beku.
type PembacaJam interface {
	Sekarang() time.Time
}

// jamBeku berangkat dari satu waktu tetap dan hanya maju bila diminta,
// sehingga urutan kejadian — dan output program — selalu sama.
type jamBeku struct {
	kini time.Time
}

func (j *jamBeku) Sekarang() time.Time { return j.kini }

// Maju menggeser jam tiruan ke depan tanpa menunggu waktu sungguhan.
func (j *jamBeku) Maju(d time.Duration) { j.kini = j.kini.Add(d) }

// Bucket menyimpan kondisi token satu kunci API.
type Bucket struct {
	sisa     float64
	terakhir time.Time
}

// Limiter menerapkan algoritma token bucket untuk banyak kunci sekaligus.
type Limiter struct {
	jam       PembacaJam
	kapasitas float64 // token saat penuh: ukuran burst
	pengisian float64 // token per detik: laju berkelanjutan
	bucket    map[string]*Bucket
}

// Izinkan menghabiskan satu token bila tersedia; token terisi ulang
// sesuai waktu yang berlalu sejak kunjungan terakhir kunci tersebut.
func (l *Limiter) Izinkan(kunci string) bool {
	now := l.jam.Sekarang()
	b, ada := l.bucket[kunci]
	if !ada {
		b = &Bucket{sisa: l.kapasitas, terakhir: now}
		l.bucket[kunci] = b
	}
	jeda := now.Sub(b.terakhir).Seconds()
	b.sisa = min(l.kapasitas, b.sisa+jeda*l.pengisian)
	b.terakhir = now
	if b.sisa >= 1 {
		b.sisa--
		return true
	}
	return false
}

// detikTunggu menghitung kapan satu token berikutnya tersedia,
// dibulatkan ke atas untuk header Retry-After.
func (l *Limiter) detikTunggu(kunci string) int {
	b := l.bucket[kunci]
	return int(math.Ceil((1 - b.sisa) / l.pengisian))
}

// middleware membatasi laju tiap permintaan berdasarkan kunci API.
func (l *Limiter) middleware(berikut http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		kunci := r.Header.Get("X-API-Key")
		if kunci == "" {
			tulisGalat(w, http.StatusUnauthorized, "MISSING_API_KEY", "kirim header X-API-Key")
			return
		}
		if !l.Izinkan(kunci) {
			w.Header().Set("Retry-After", strconv.Itoa(l.detikTunggu(kunci)))
			tulisGalat(w, http.StatusTooManyRequests, "RATE_LIMIT_EXCEEDED",
				"batas laju terlampaui; ulangi setelah Retry-After")
			return
		}
		berikut.ServeHTTP(w, r)
	})
}

// laporan adalah endpoint yang dilindungi limiter.
func laporan(w http.ResponseWriter, r *http.Request) {
	tulisJSON(w, http.StatusOK, map[string]string{"data": "ringkasan-penjualan"})
}

type galatBody struct {
	Error galatRincian `json:"error"`
}

type galatRincian struct {
	Code    string `json:"code"`
	Message string `json:"message"`
}

func tulisGalat(w http.ResponseWriter, status int, kode, pesan string) {
	tulisJSON(w, status, galatBody{Error: galatRincian{Code: kode, Message: pesan}})
}

func tulisJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func main() {
	jam := &jamBeku{kini: time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)}
	limiter := &Limiter{
		jam:       jam,
		kapasitas: 3, // burst: tiga permintaan boleh sekaligus
		pengisian: 1, // sustained: rata-rata satu permintaan per detik
		bucket:    make(map[string]*Bucket),
	}
	mux := http.NewServeMux()
	mux.HandleFunc("GET /laporan", laporan)
	server := limiter.middleware(mux)

	// Bagian 1: burst — lima permintaan berturut-turut tanpa jeda waktu.
	for nomor := 1; nomor <= 5; nomor++ {
		kirim(server, fmt.Sprintf("#%d kunci-budi", nomor), "kunci-budi")
	}

	// Bagian 2: satu detik berlalu — satu token terisi kembali.
	fmt.Println("-- jam dimajukan 1 detik --")
	jam.Maju(time.Second)
	kirim(server, "#6 kunci-budi", "kunci-budi")

	// Bagian 3: kunci lain punya bucket sendiri, tidak ikut terblokir.
	kirim(server, "kunci-siti", "kunci-siti")

	// Bagian 4: tanpa kunci, limiter tidak punya identitas untuk dihitung.
	kirim(server, "tanpa-kunci", "")
}

// kirim mengeksekusi satu permintaan GET dengan kunci API tertentu,
// lalu mencetak status, header Retry-After, dan body.
func kirim(server http.Handler, label, kunci string) {
	req := httptest.NewRequest(http.MethodGet, "/laporan", nil)
	if kunci != "" {
		req.Header.Set("X-API-Key", kunci)
	}
	rec := httptest.NewRecorder()
	server.ServeHTTP(rec, req)
	fmt.Printf("%-5s %-9s %-13s -> %d", "GET", "/laporan", label, rec.Code)
	if coba := rec.Header().Get("Retry-After"); coba != "" {
		fmt.Printf(" (Retry-After: %s)", coba)
	}
	fmt.Println()
	if rec.Body.Len() > 0 {
		fmt.Printf("   %s", rec.Body.String())
	}
}

Output yang Dihasilkan:

text
GET   /laporan  #1 kunci-budi -> 200
   {"data":"ringkasan-penjualan"}
GET   /laporan  #2 kunci-budi -> 200
   {"data":"ringkasan-penjualan"}
GET   /laporan  #3 kunci-budi -> 200
   {"data":"ringkasan-penjualan"}
GET   /laporan  #4 kunci-budi -> 429 (Retry-After: 1)
   {"error":{"code":"RATE_LIMIT_EXCEEDED","message":"batas laju terlampaui; ulangi setelah Retry-After"}}
GET   /laporan  #5 kunci-budi -> 429 (Retry-After: 1)
   {"error":{"code":"RATE_LIMIT_EXCEEDED","message":"batas laju terlampaui; ulangi setelah Retry-After"}}
-- jam dimajukan 1 detik --
GET   /laporan  #6 kunci-budi -> 200
   {"data":"ringkasan-penjualan"}
GET   /laporan  kunci-siti    -> 200
   {"data":"ringkasan-penjualan"}
GET   /laporan  tanpa-kunci   -> 401
   {"error":{"code":"MISSING_API_KEY","message":"kirim header X-API-Key"}}

Mari kita telaah baris demi baris program di atas:

  1. Baris 13-17 (PembacaJam): interface satu metode ini adalah titik suntikan waktu — satu-satunya tempat program bertanya “sekarang jam berapa”.
  2. Baris 19-28 (jamBeku): jam tiruan menyimpan satu waktu; Sekarang mengembalikannya, Maju menggesernya tanpa menunggu waktu sungguhan.
  3. Baris 36-42 (type Limiter): dua angka kebijakan (kapasitas, pengisian) dan map bucket per kunci menjadi satu struct yang mudah dikonfigurasi ulang.
  4. Baris 46-61 (Izinkan): jeda sejak kunjungan terakhir dikalikan laju pengisian lalu ditambah ke sisa token dengan batas min kapasitas; token tersedia bila sisa minimal satu.
  5. Baris 63-68 (detikTunggu): defisit satu token dibagi pengisian lalu dibulatkan ke atas dengan math.Ceil — bilangan inilah yang menjadi Retry-After.
  6. Baris 70-86 (middleware): permintaan tanpa X-API-Key dijawab 401 karena limiter butuh identitas; permintaan yang ditolak menerima 429 dan Retry-After.
  7. Baris 113-119 (main): jam beku dibangun dari time.Date tetap; limiter dikonfigurasi dengan burst 3 dan pengisian 1 per detik.
  8. Baris 124-132 (dua bagian pertama): lima permintaan terjadi pada satu momen waktu sehingga hanya tiga lewat; jam.Maju kemudian mengisi ulang satu token untuk permintaan keenam.
  9. Baris 134-138 (dua bagian terakhir): kunci siti membuktikan isolasi antar bucket, sementara permintaan tanpa kunci menutup demo dengan 401.

Output berjalan runtut: tiga permintaan pertama lewat (burst habis), dua berikutnya ditolak 429 dengan Retry-After: 1, permintaan keenam lewat setelah satu detik sintetis, dan kunci siti tidak terpengaruh. Kebijakan laju kini menjaga ketersediaan; keputusan berikutnya menjaga kontrak saat API berkembang — versioning, chapter berikutnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

ratelimit-demo.go

Unduh berkas
go
package main

import (
	"encoding/json"
	"fmt"
	"math"
	"net/http"
	"net/http/httptest"
	"strconv"
	"time"
)

// PembacaJam adalah sumber waktu yang bisa disuntik. Produksi memakai
// pembungkus tipis di atas time.Now; demo memakai jam beku.
type PembacaJam interface {
	Sekarang() time.Time
}

// jamBeku berangkat dari satu waktu tetap dan hanya maju bila diminta,
// sehingga urutan kejadian — dan output program — selalu sama.
type jamBeku struct {
	kini time.Time
}

func (j *jamBeku) Sekarang() time.Time { return j.kini }

// Maju menggeser jam tiruan ke depan tanpa menunggu waktu sungguhan.
func (j *jamBeku) Maju(d time.Duration) { j.kini = j.kini.Add(d) }

// Bucket menyimpan kondisi token satu kunci API.
type Bucket struct {
	sisa     float64
	terakhir time.Time
}

// Limiter menerapkan algoritma token bucket untuk banyak kunci sekaligus.
type Limiter struct {
	jam       PembacaJam
	kapasitas float64 // token saat penuh: ukuran burst
	pengisian float64 // token per detik: laju berkelanjutan
	bucket    map[string]*Bucket
}

// Izinkan menghabiskan satu token bila tersedia; token terisi ulang
// sesuai waktu yang berlalu sejak kunjungan terakhir kunci tersebut.
func (l *Limiter) Izinkan(kunci string) bool {
	now := l.jam.Sekarang()
	b, ada := l.bucket[kunci]
	if !ada {
		b = &Bucket{sisa: l.kapasitas, terakhir: now}
		l.bucket[kunci] = b
	}
	jeda := now.Sub(b.terakhir).Seconds()
	b.sisa = min(l.kapasitas, b.sisa+jeda*l.pengisian)
	b.terakhir = now
	if b.sisa >= 1 {
		b.sisa--
		return true
	}
	return false
}

// detikTunggu menghitung kapan satu token berikutnya tersedia,
// dibulatkan ke atas untuk header Retry-After.
func (l *Limiter) detikTunggu(kunci string) int {
	b := l.bucket[kunci]
	return int(math.Ceil((1 - b.sisa) / l.pengisian))
}

// middleware membatasi laju tiap permintaan berdasarkan kunci API.
func (l *Limiter) middleware(berikut http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		kunci := r.Header.Get("X-API-Key")
		if kunci == "" {
			tulisGalat(w, http.StatusUnauthorized, "MISSING_API_KEY", "kirim header X-API-Key")
			return
		}
		if !l.Izinkan(kunci) {
			w.Header().Set("Retry-After", strconv.Itoa(l.detikTunggu(kunci)))
			tulisGalat(w, http.StatusTooManyRequests, "RATE_LIMIT_EXCEEDED",
				"batas laju terlampaui; ulangi setelah Retry-After")
			return
		}
		berikut.ServeHTTP(w, r)
	})
}

// laporan adalah endpoint yang dilindungi limiter.
func laporan(w http.ResponseWriter, r *http.Request) {
	tulisJSON(w, http.StatusOK, map[string]string{"data": "ringkasan-penjualan"})
}

type galatBody struct {
	Error galatRincian `json:"error"`
}

type galatRincian struct {
	Code    string `json:"code"`
	Message string `json:"message"`
}

func tulisGalat(w http.ResponseWriter, status int, kode, pesan string) {
	tulisJSON(w, status, galatBody{Error: galatRincian{Code: kode, Message: pesan}})
}

func tulisJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func main() {
	jam := &jamBeku{kini: time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)}
	limiter := &Limiter{
		jam:       jam,
		kapasitas: 3, // burst: tiga permintaan boleh sekaligus
		pengisian: 1, // sustained: rata-rata satu permintaan per detik
		bucket:    make(map[string]*Bucket),
	}
	mux := http.NewServeMux()
	mux.HandleFunc("GET /laporan", laporan)
	server := limiter.middleware(mux)

	// Bagian 1: burst — lima permintaan berturut-turut tanpa jeda waktu.
	for nomor := 1; nomor <= 5; nomor++ {
		kirim(server, fmt.Sprintf("#%d kunci-budi", nomor), "kunci-budi")
	}

	// Bagian 2: satu detik berlalu — satu token terisi kembali.
	fmt.Println("-- jam dimajukan 1 detik --")
	jam.Maju(time.Second)
	kirim(server, "#6 kunci-budi", "kunci-budi")

	// Bagian 3: kunci lain punya bucket sendiri, tidak ikut terblokir.
	kirim(server, "kunci-siti", "kunci-siti")

	// Bagian 4: tanpa kunci, limiter tidak punya identitas untuk dihitung.
	kirim(server, "tanpa-kunci", "")
}

// kirim mengeksekusi satu permintaan GET dengan kunci API tertentu,
// lalu mencetak status, header Retry-After, dan body.
func kirim(server http.Handler, label, kunci string) {
	req := httptest.NewRequest(http.MethodGet, "/laporan", nil)
	if kunci != "" {
		req.Header.Set("X-API-Key", kunci)
	}
	rec := httptest.NewRecorder()
	server.ServeHTTP(rec, req)
	fmt.Printf("%-5s %-9s %-13s -> %d", "GET", "/laporan", label, rec.Code)
	if coba := rec.Header().Get("Retry-After"); coba != "" {
		fmt.Printf(" (Retry-After: %s)", coba)
	}
	fmt.Println()
	if rec.Body.Len() > 0 {
		fmt.Printf("   %s", rec.Body.String())
	}
}