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:

js
// 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:

text
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:

  1. Baris 2-3 (buatLimiter + Map): limiter dibungkus sebagai factory — setiap pemanggilan buatLimiter() mendapat Map tersendiri, sehingga limiter untuk endpoint login tidak bercampur dengan limiter endpoint lain.
  2. Baris 10-13 (jendela baru): dua kondisi memulai jendela — catatan belum ada, atau selisih waktu sudah melewati jendelaMs. Permintaan pertama jendela selalu diizinkan dan kuota sisa dihitung ulang dari batas penuh.
  3. Baris 15-16 (dalam jendela): jumlah bertambah, dan izin ditentukan oleh perbandingan sederhana jumlah <= batas. Math.max(..., 0) menjaga sisa tidak bernilai negatif — angka yang rapi untuk ditampilkan ke client.
  4. Baris 22-25 (injeksi jam): parameter sekarang adalah satu-satunya sumber waktu. Produksi mengisi Date.now; demo mengisi jamPalsu. Pola injeksi dependensi ini membuat logika waktu bisa diuji secara penuh — teknik yang juga dipakai testing.
  5. 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:

js
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:

text
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:

AlgoritmaCara kerjaKarakter
Fixed windowHitung per jendela tetapSederhana; lonjakan di tepi jendela
Sliding windowGeser jendela mengikuti waktuHalus; butuh menyimpan riwayat
Token bucketToken terisi perlahan, permintaan memakai tokenMengizinkan 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:

js Pola express-rate-limit (ilustrasi - package tidak terpasang di lingkungan Course)
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.