Pertahanan pada chapter-chapter sebelumnya menghadapi penyerang yang mengirim masukan cerdas. Penyalahgunaan kedua datang tanpa kecerdikan apa pun: cukup volume. Seratus percobaan login per menit untuk menebak sandi, ribuan permintaan per detik untuk melumat endpoint, atau crawling data massal lewat API publik — semuanya memakai jalur yang sah dengan frekuensi yang tidak wajar. Rate limiting adalah lapisan yang mengatur volume: setiap identitas (biasanya alamat IP atau akun) hanya boleh melakukan sejumlah permintaan dalam satu jendela waktu. Chapter ini membangun pola fixed window dari nol sebagai function murni, memasangnya ke server node:http, lalu membahas keterbatasannya di skala nyata.
Pola Fixed Window #
Algoritma paling sederhana bekerja seperti meteran parkir: penghitung per kunci, direset setiap jendela waktu berlalu. Permintaan pertama menciptakan catatan berisi waktu mulai dan jumlah 1; setiap permintaan berikutnya menambah jumlah; bila jumlah melewati batas, permintaan ditolak hingga jendela berikutnya dimulai. Agar pergantian jendela bisa kita amati tanpa menunggu enam puluh detik, demo memakai jam palsu — function sekarang() yang waktunya hanya maju ketika kita majukan:
// Fixed-window rate limiter: penghitung per kunci dalam jendela waktu.
function buatLimiter({ batas, jendelaMs, sekarang }) {
const hitung = new Map();
return function periksa(kunci) {
const t = sekarang();
const catatan = hitung.get(kunci);
// Jendela baru: permintaan pertama, atau jendela lama sudah berlalu.
if (catatan === undefined || t - catatan.mulai >= jendelaMs) {
hitung.set(kunci, { mulai: t, jumlah: 1 });
return { diizinkan: true, sisa: batas - 1 };
}
catatan.jumlah += 1;
return { diizinkan: catatan.jumlah <= batas, sisa: Math.max(batas - catatan.jumlah, 0) };
};
}
// Jam palsu: waktu hanya bergerak saat kita majukan sendiri,
// sehingga pergantian jendela bisa didemokan tanpa menunggu.
let waktuPalsu = 1_000_000;
const jamPalsu = () => waktuPalsu;
const periksa = buatLimiter({ batas: 3, jendelaMs: 60_000, sekarang: jamPalsu });
for (let i = 1; i <= 4; i++) {
console.log(`permintaan ${i} dari 203.0.113.10 ->`, JSON.stringify(periksa('203.0.113.10')));
}
console.log('permintaan lain dari 198.51.100.7 ->', JSON.stringify(periksa('198.51.100.7')));
waktuPalsu += 60_000; // satu jendela penuh berlalu
console.log('permintaan 5 (jendela baru) ->', JSON.stringify(periksa('203.0.113.10')));Output yang Dihasilkan:
permintaan 1 dari 203.0.113.10 -> {"diizinkan":true,"sisa":2}
permintaan 2 dari 203.0.113.10 -> {"diizinkan":true,"sisa":1}
permintaan 3 dari 203.0.113.10 -> {"diizinkan":true,"sisa":0}
permintaan 4 dari 203.0.113.10 -> {"diizinkan":false,"sisa":0}
permintaan lain dari 198.51.100.7 -> {"diizinkan":true,"sisa":2}
permintaan 5 (jendela baru) -> {"diizinkan":true,"sisa":2}Mari kita bedah perilakunya dari output di atas:
- Baris 2-3 (
buatLimiter+Map): limiter dibungkus sebagai factory — setiap pemanggilanbuatLimiter()mendapatMaptersendiri, sehingga limiter untuk endpoint login tidak bercampur dengan limiter endpoint lain. - Baris 10-13 (jendela baru): dua kondisi memulai jendela — catatan belum ada, atau selisih waktu sudah melewati
jendelaMs. Permintaan pertama jendela selalu diizinkan dan kuotasisadihitung ulang dari batas penuh. - Baris 15-16 (dalam jendela): jumlah bertambah, dan izin ditentukan oleh perbandingan sederhana
jumlah <= batas.Math.max(..., 0)menjagasisatidak bernilai negatif — angka yang rapi untuk ditampilkan ke client. - Baris 22-25 (injeksi jam): parameter
sekarangadalah satu-satunya sumber waktu. Produksi mengisiDate.now; demo mengisijamPalsu. Pola injeksi dependensi ini membuat logika waktu bisa diuji secara penuh — teknik yang juga dipakai testing. - Baris 27-33 (skenario): empat permintaan beruntun dari satu IP, satu permintaan dari IP tetangga, lalu permintaan kelima setelah jam maju satu jendela. Perhatikan baris “permintaan lain”: penghitung bersifat per-kunci, jadi IP kedua tidak dihukum oleh kebisingan IP pertama.
Rate Limiter di Server HTTP Nyata #
Logika yang sama kini dipasang pada server sungguhan. Identitas diambil dari req.socket.remoteAddress, batas ditetapkan tiga permintaan per menit, dan penolakan dibalas dengan status 429 beserta header Retry-After:
import http from 'node:http';
// Penghitung per IP: hanya hidup selama proses ini berjalan.
const BATAS = 3;
const JENDELA_MS = 60_000;
const hitung = new Map(); // ip -> { mulai, jumlah }
function periksaBatas(ip) {
const t = Date.now();
const catatan = hitung.get(ip);
if (catatan === undefined || t - catatan.mulai >= JENDELA_MS) {
hitung.set(ip, { mulai: t, jumlah: 1 });
return true;
}
catatan.jumlah += 1;
return catatan.jumlah <= BATAS;
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://127.0.0.1`);
const kirim = (kode, data, headerTambahan) => {
res.writeHead(kode, { 'Content-Type': 'application/json', ...headerTambahan });
res.end(JSON.stringify(data));
};
if (req.method === 'GET' && url.pathname === '/api/saldo') {
const ip = req.socket.remoteAddress ?? 'tak-dikenal';
if (periksaBatas(ip) === false) {
return kirim(429, { error: 'Terlalu banyak permintaan' }, { 'Retry-After': '60' });
}
return kirim(200, { saldo: 500000 });
}
return kirim(404, { error: 'Endpoint tidak ditemukan' });
});
server.listen(0, '127.0.0.1', async () => {
const base = `http://127.0.0.1:${server.address().port}`;
for (let i = 1; i <= 5; i++) {
const r = await fetch(`${base}/api/saldo`);
console.log(`permintaan ${i} -> ${r.status}`, await r.text());
}
server.close();
server.closeAllConnections();
});Output yang Dihasilkan:
permintaan 1 -> 200 {"saldo":500000}
permintaan 2 -> 200 {"saldo":500000}
permintaan 3 -> 200 {"saldo":500000}
permintaan 4 -> 429 {"error":"Terlalu banyak permintaan"}
permintaan 5 -> 429 {"error":"Terlalu banyak permintaan"}Tiga permintaan pertama dilayani 200, dua berikutnya ditolak 429 — dan seluruh transkrip tetap rapi karena yang dicetak hanya status dan body. Titik pemasangannya di baris 28-34 patut diperhatikan: pemeriksaan batas berjalan sebelum logika endpoint. Membalas 429 di depan berarti penyerang tidak pernah menghabiskan waktu CPU, koneksi database, apalagi logika bisnis aplikasi kita — kebalikan dari memvalidasi sesudah memproses.
Status 429 sendiri adalah kode resmi untuk “too many requests”; datang bersama header Retry-After
yang memberi tahu client berapa lama harus menunggu (dalam detik) sebelum mencoba lagi. Client yang sopan — termasuk crawler mesin pencari — menghormati nilai ini; client yang mengabaikannya tetap ditolak oleh penghitung kita. Rincian semantik status ini terdokumentasi di MDN: 429 Too Many Requests
.
Keterbatasan dan Jalan Naik #
Fixed window punya kelemahan yang jelas dari bentuknya: penyerang bisa mengirim seluruh beban di punggung jendela — tiga permintaan di detik terakhir jendela lama plus tiga di detik pertama jendela baru menghasilkan lonjakan keenam dalam dua detik. Algoritma pengganti menyempurnakan hal ini:
| Algoritma | Cara kerja | Karakter |
|---|---|---|
| Fixed window | Hitung per jendela tetap | Sederhana; lonjakan di tepi jendela |
| Sliding window | Geser jendela mengikuti waktu | Halus; butuh menyimpan riwayat |
| Token bucket | Token terisi perlahan, permintaan memakai token | Mengizinkan lonjakan singkat yang terkendali |
Untuk aplikasi Express, package express-rate-limit menyiapkan pola-pola ini sebagai middleware; idenya sama dengan yang kita bangun sendiri di atas:
import rateLimit from 'express-rate-limit';
app.use('/api/', rateLimit({
windowMs: 60_000,
limit: 100,
standardHeaders: 'draft-7',
}));Ada satu kategori aset yang sampai di sini masih kita simpan dengan cara yang buruk: kredensial, API key, dan connection string yang tertanam langsung di dalam kode. Chapter penutup section ini, Manajemen Secret, membereskannya.