API yang terbuka untuk publik akan menerima permintaan dari siapa saja: aplikasi mobile yang wajar, skrip yang agresif, hingga bot yang menyalahgunakan layanan secara sengaja. Tanpa pembatasan, satu klien saja dapat mengirim ribuan permintaan per detik dan membuat server, database, atau layanan pihak ketiga (misalnya penyedia email) lumpuh. Rate limiting adalah teknik membatasi jumlah permintaan yang boleh dikirim sebuah klien dalam satuan waktu tertentu. Pada chapter ini kita akan membangun tiga algoritma pembatas yang umum dipakai, lalu menyusun respons 429 Too Many Requests yang informatif beserta header standarnya.

Mengapa API Perlu Rate Limiting #

Rate limiting menyelesaikan tiga masalah sekaligus. Pertama, perlindungan dari penyalahgunaan: endpoint login yang tidak dibatasi memungkinkan penyerang mencoba jutaan kombinasi kata sandi (serangan brute force). Kedua, stabilitas layanan: beban puncak dari satu klien rakus tidak boleh membuat seluruh pengguna lain ikut lambat atau gagal. Ketiga, keadilan penggunaan (fairness): pada paket berbayar, batas laju memisahkan kuota pelanggan gratis dari pelanggan premium.

Batasan laju selalu didefinisikan dengan dua angka: jumlah maksimum permintaan dan jendela waktunya, misalnya “3 permintaan per 60 detik”. Seluruh algoritma pada chapter ini memakai bentuk keputusan yang sama, sehingga kita bisa membandingkan perilakunya secara adil. Keputusan tersebut menjawab satu pertanyaan: apakah permintaan ini diizinkan, dan bila tidak, kapan klien boleh mencoba lagi.

Fixed Window Counter #

Algoritma fixed window counter membagi garis waktu menjadi jendela tetap, misalnya [960, 1020), [1020, 1080), dan seterusnya untuk jendela 60 detik. Setiap jendela memiliki penghitung sendiri yang dimulai dari nol. Permintaan diizinkan selama penghitung jendela saat ini masih di bawah batas; begitu jendela baru dimulai, penghitung otomatis kembali ke nol.

Agar mudah diuji, kita menulis algoritma ini sebagai fungsi murni: waktu “sekarang” dioper sebagai parameter $now (bukan memanggil time()), dan penyimpanan counter dioper sebagai array $state yang dikembalikan lagi setelah diperbarui. Dengan begitu kita bisa menjalankan skenario waktu apa pun secara deterministik:

php
<?php

function checkRateLimit(array $state, string $key, int $limit, int $windowSeconds, int $now): array
{
    $windowStart = intdiv($now, $windowSeconds) * $windowSeconds;

    $entry = $state[$key] ?? ['window_start' => 0, 'count' => 0];
    if ($entry['window_start'] !== $windowStart) {
        $entry = ['window_start' => $windowStart, 'count' => 0];
    }

    $allowed = $entry['count'] < $limit;
    if ($allowed) {
        $entry['count']++;
    }
    $state[$key] = $entry;

    return [
        'allowed'     => $allowed,
        'remaining'   => max(0, $limit - $entry['count']),
        'reset_at'    => $windowStart + $windowSeconds,
        'retry_after' => $allowed ? 0 : $windowStart + $windowSeconds - $now,
        'state'       => $state,
    ];
}

$state = [];
$permintaan = [
    ['klien-A', 1005],
    ['klien-A', 1012],
    ['klien-A', 1018],
    ['klien-A', 1019],
    ['klien-A', 1025],
    ['klien-B', 1012],
];

foreach ($permintaan as [$key, $now]) {
    $hasil = checkRateLimit($state, $key, 3, 60, $now);
    $state = $hasil['state'];
    printf(
        "klien=%s t=%d diizinkan=%-5s sisa=%d reset_pada=%d\n",
        $key,
        $now,
        $hasil['allowed'] ? 'ya' : 'tidak',
        $hasil['remaining'],
        $hasil['reset_at']
    );
}

Output yang Dihasilkan:

text
klien=klien-A t=1005 diizinkan=ya    sisa=2 reset_pada=1020
klien=klien-A t=1012 diizinkan=ya    sisa=1 reset_pada=1020
klien=klien-A t=1018 diizinkan=ya    sisa=0 reset_pada=1020
klien=klien-A t=1019 diizinkan=tidak sisa=0 reset_pada=1020
klien=klien-A t=1025 diizinkan=ya    sisa=2 reset_pada=1080
klien=klien-B t=1012 diizinkan=ya    sisa=2 reset_pada=1020

Mari kita telaah bagian-bagian pentingnya:

  1. Baris intdiv($now, $windowSeconds) * $windowSeconds: menghitung titik awal jendela. Untuk $now = 1005 dan jendela 60 detik, hasilnya 960, artinya permintaan jatuh di jendela [960, 1020).
  2. Blok if ($entry['window_start'] !== $windowStart): bila penghitung yang tersimpan berasal dari jendela lama, penghitung direset ke nol — inilah mekanisme “jendela tetap” dari algoritma ini.
  3. Baris $allowed = $entry['count'] < $limit;: penghitung dinaikkan hanya bila permintaan diizinkan, sehingga permintaan yang ditolak tidak menghabiskan kuota.
  4. Kunci $state[$key]: setiap klien memiliki penghitung sendiri; terlihat pada output bahwa klien-B tetap diizinkan meskipun klien-A sudah jenuh.

Perhatikan dua perilaku pada output: permintaan keempat klien-A pada t=1019 ditolak karena jendela [960, 1020) sudah penuh, lalu permintaan pada t=1025 langsung diizinkan lagi karena masuk jendela baru [1020, 1080).

Sliding Window #

Sliding window tidak lagi memotong waktu menjadi jendela tetap, melainkan menatap ke belakang sebanyak N detik dari waktu sekarang. Versi paling sederhana menyimpan daftar cap waktu (timestamp) setiap permintaan, lalu menghitung berapa banyak yang berumur kurang dari lebar jendela. Implementasi berikut tetap deterministik karena daftar cap waktu dan waktu “sekarang” sama-sama dioper sebagai argumen:

php
<?php

function countRecentHits(array $hitTimes, int $windowSeconds, int $now): int
{
    $recent = array_filter($hitTimes, fn (int $t): bool => $t > $now - $windowSeconds);
    return count($recent);
}

$hitTimes = [40, 55, 61, 70, 95];
$now = 100;

echo "jendela 30 detik: " . countRecentHits($hitTimes, 30, $now) . " permintaan terhitung\n";
echo "jendela 60 detik: " . countRecentHits($hitTimes, 60, $now) . " permintaan terhitung\n";

Output yang Dihasilkan:

text
jendela 30 detik: 1 permintaan terhitung
jendela 60 detik: 4 permintaan terhitung

Fungsi countRecentHits() menyaring daftar $hitTimes dan hanya menyimpan cap waktu yang lebih baru dari $now - $windowSeconds. Pada jendela 30 detik (batas t > 70), hanya permintaan di t=95 yang terhitung; pada jendela 60 detik (batas t > 40), empat permintaan terhitung. Karena jendela bergeser mengikuti waktu sekarang, ledakan permintaan di batas jendela tidak lagi “tersembunyi” seperti pada fixed window. Konsekuensinya, menyimpan setiap cap waktu memakan memori O(n) per klien, sehingga implementasi produksi biasanya memakai ring buffer berukuran tetap atau hampiran (approximation) seperti menggabungkan beberapa fixed window kecil dengan bobot.

Token Bucket #

Token bucket bekerja seperti tabungan kupon: bucket menampung hingga $capacity token, dan setiap permintaan harus “membayar” satu token untuk diizinkan. Token menetes kembali ke bucket dengan laju $refillPerSecond, sehingga klien boleh sesekali meledak (burst) selama tabungannya cukup, tetapi rata-rata lajunya tetap terkendala. Simulasi berikut memakai cap waktu tetap agar hasilnya dapat diperiksa ulang:

php
<?php

function tokenBucket(array $bucket, float $capacity, float $refillPerSecond, float $now): array
{
    $elapsed = max(0.0, $now - $bucket['last_refill']);
    $tokens = min($capacity, $bucket['tokens'] + $elapsed * $refillPerSecond);

    $allowed = $tokens >= 1.0;
    if ($allowed) {
        $tokens -= 1.0;
    }

    return [
        'allowed' => $allowed,
        'bucket'  => ['tokens' => $tokens, 'last_refill' => $now],
    ];
}

$bucket = ['tokens' => 1.0, 'last_refill' => 0.0];

foreach ([0, 1, 2, 4, 5, 6, 10] as $now) {
    $result = tokenBucket($bucket, 4.0, 0.5, (float) $now);
    $bucket = $result['bucket'];
    printf(
        "t=%2ds diizinkan=%-5s token=%.2f\n",
        $now,
        $result['allowed'] ? 'ya' : 'tidak',
        $bucket['tokens']
    );
}

Output yang Dihasilkan:

text
t= 0s diizinkan=ya    token=0.00
t= 1s diizinkan=tidak token=0.50
t= 2s diizinkan=ya    token=0.00
t= 4s diizinkan=ya    token=0.00
t= 5s diizinkan=tidak token=0.50
t= 6s diizinkan=ya    token=0.00
t=10s diizinkan=ya    token=1.00

Mari kita bedah logikanya:

  1. Baris $elapsed = max(0.0, ...): menghitung selisih waktu sejak pengisian terakhir; max menjaga nilainya tidak negatif.
  2. Baris $tokens = min($capacity, ...): menambah token sesuai waktu yang berlalu, tetapi tidak pernah melebihi kapasitas bucket — token yang meluap hilang.
  3. Baris $allowed = $tokens >= 1.0;: permintaan hanya diizinkan bila tersedia sedikitnya satu token; token lalu dikurangi satu.
  4. Nilai kembalian bucket: state baru dikembalikan ke pemanggil, menjaga fungsi tetap murni dan mudah diuji.

Pada output terlihat karakter khas token bucket: permintaan pada t=1 dan t=5 ditolak karena token belum menetes cukup (0,50), sementara jeda panjang hingga t=10 menabung 2 token sehingga permintaan diizinkan dan masih menyisakan 1,00.

Respons 429 dan Header Rate Limit #

Menolak permintaan tanpa penjelasan membuat klien sulit bereaksi dengan benar. Praktik yang baik adalah mengirim status 429 Too Many Requests beserta header Retry-After (berapa detik klien harus menunggu) dan header X-RateLimit-* yang menjelaskan kondisi kuota. Di lingkungan CLI kita membangun respons sebagai array murni; di handler nyata, array inilah yang dikirim lewat http_response_code() dan header():

php
<?php

function buildRateLimitResponse(array $decision, int $limit): array
{
    if ($decision['allowed']) {
        return ['status' => 200, 'body' => ['ok' => true]];
    }

    return [
        'status'  => 429,
        'headers' => [
            'Retry-After'           => (string) $decision['retry_after'],
            'X-RateLimit-Limit'     => (string) $limit,
            'X-RateLimit-Remaining' => '0',
            'X-RateLimit-Reset'     => (string) $decision['reset_at'],
        ],
        'body' => [
            'error'   => 'rate_limit_exceeded',
            'message' => 'Terlalu banyak permintaan, coba lagi nanti.',
        ],
    ];
}

$decision = ['allowed' => false, 'remaining' => 0, 'reset_at' => 1020, 'retry_after' => 7];
$response = buildRateLimitResponse($decision, 3);

echo "HTTP/1.1 {$response['status']} Too Many Requests\n";
foreach ($response['headers'] as $nama => $nilai) {
    echo "$nama: $nilai\n";
}
echo json_encode($response['body'], JSON_UNESCAPED_UNICODE), "\n";

Output yang Dihasilkan:

text
HTTP/1.1 429 Too Many Requests
Retry-After: 7
X-RateLimit-Limit: 3
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1020
{"error":"rate_limit_exceeded","message":"Terlalu banyak permintaan, coba lagi nanti."}

Fungsi buildRateLimitResponse() menerima keputusan dari algoritma pembatas dan menerjemahkannya menjadi respons HTTP yang lengkap. Nilai Retry-After: 7 memberi tahu klien bahwa percobaan ulang sebelum 7 detik hanya akan sia-sia, sedangkan X-RateLimit-Reset: 1020 memberi cap waktu absolut kapan kuota pulih — dua gaya informasi yang berbeda untuk jenis klien yang berbeda. Header X-RateLimit-* bukan standar resmi RFC, tetapi telah menjadi konvensi umum yang dipahami hampir semua SDK. Detail status code lainnya pernah kita bahas pada chapter Status Codes dan Error Responses .

Menentukan Kunci Pembatas #

Batasan laju hanya adil bila diterapkan pada identitas yang tepat. Mengikat counter ke API key lebih akurat karena satu key berarti satu klien terdaftar; memakai alamat IP lebih sederhana tetapi keliru di balik NAT atau proxy perusahaan (banyak pengguna berbagi satu IP). Kebijakan yang umum: pakai API key bila ada, jatuh ke IP bila endpoint bersifat publik-anonim:

php
<?php

function resolveClientKey(array $server): string
{
    $apiKey = $server['HTTP_X_API_KEY'] ?? null;
    if (is_string($apiKey) && $apiKey !== '') {
        return 'api-key:' . hash('sha256', $apiKey);
    }

    return 'ip:' . ($server['REMOTE_ADDR'] ?? '0.0.0.0');
}

echo resolveClientKey(['HTTP_X_API_KEY' => 'YOUR_API_KEY', 'REMOTE_ADDR' => '203.0.113.10']), "\n";
echo resolveClientKey(['REMOTE_ADDR' => '198.51.100.7']), "\n";

Output yang Dihasilkan:

text
api-key:358b5f6606b3fb8e6aaed3d7b3cda482bbf3201965d2e6e45ffcb40b4852a36b
ip:198.51.100.7

Fungsi resolveClientKey() menerima array $server (meniru superglobal $_SERVER) sehingga tetap dapat diuji tanpa server sungguhan. API key di-hash terlebih dahulu (hash('sha256', $apiKey)) sebelum dipakai sebagai kunci, agar nilai rahasia tidak tersebar ke seluruh sistem penyimpanan counter. Ringkasan kedua pendekatan ini:

AspekPer API KeyPer IP
Akurasi identitasTinggi; satu key = satu pemilikRendah di NAT/proxy; ribuan orang bisa satu IP
Klien tanpa loginTidak bisa dipakaiSelalu bisa dipakai
Manipulasi oleh klienKey rahasia, sulit dipalsukanRentan spoofing bila server percaya X-Forwarded-For mentah
Cocok untukAPI berbayar, partner B2BEndpoint publik, form kontak

Penyimpanan Counter di Production #

Array $state pada contoh kita hidup hanya selama satu proses PHP — cukup untuk pengujian, tidak untuk produksi. Server web menjalankan banyak proses PHP yang tidak berbagi memori, dan setiap proses punya $state sendiri, sehingga batas laju menjadi tidak akurat. Di produksi, counter harus tinggal di penyimpanan bersama yang cepat dan atomik; pilihan paling umum adalah Redis:

php
<?php
// Konsep counter fixed window di Redis (butuh server Redis; tidak
// tersedia di lingkungan belajar ini). INCR dan EXPIRE dieksekusi
// oleh server Redis secara atomik, aman dipanggil banyak proses PHP.
//
// $redis = new Redis();
// $redis->connect('127.0.0.1', 6379);
// $windowKey = 'ratelimit:' . $apiKey . ':' . intdiv(time(), 60);
// $count = $redis->incr($windowKey);
// if ($count === 1) {
//     $redis->expire($windowKey, 60); // hapus otomatis saat jendela lewat
// }

Perintah INCR menaikkan counter dan mengembalikan nilai barunya dalam satu operasi di sisi server Redis, sehingga dua proses PHP yang balapan tidak akan saling menimpa. Perintah EXPIRE menghapus kunci jendela lama secara otomatis agar memori Redis tidak membengkak.

Referensi #

Ringkasan #

Rate limiting menjaga API tetap stabil, adil, dan tidak mudah disalahgunakan dengan membatasi jumlah permintaan per satuan waktu. Fixed window counter paling sederhana tetapi longgar di batas jendela, sliding window lebih presisi dengan biaya memori, dan token bucket paling fleksibel karena mengizinkan burst terkendali. Keputusan penolakan dikomunikasikan lewat status 429, header Retry-After, dan header X-RateLimit-*, sementara counter produksi disimpan di penyimpanan atomik seperti Redis. Pembatasan laju menahan permintaan yang terlalu sering; pada chapter berikutnya kita menangani masalah sebaliknya — permintaan yang sama persis terkirim dua kali — dengan pola idempotency.