Pada sistem Laravel yang berjalan di beberapa node aplikasi, keluhan tentang rate limit yang tidak konsisten cukup umum: sebagian request terasa lolos tanpa dibatasi, sementara di kondisi lain request yang seharusnya aman justru terlalu cepat kena 429 Too Many Requests. Masalah ini jarang disebabkan oleh satu hal saja. Biasanya ada kombinasi faktor seperti store throttle yang tidak benar-benar dibagi, prefix key berbeda antar node, IP client terbaca tidak konsisten karena proxy, atau perilaku Redis Cluster yang tidak diperhitungkan.

Artikel ini membahas penyebab teknis yang paling sering muncul dan langkah praktis untuk membuat throttle API Laravel lebih stabil, terutama untuk API internal yang melewati load balancer, reverse proxy, atau service mesh.

Mengapa throttle terasa lolos atau terlalu ketat?

Secara konsep, rate limiter Laravel menyimpan hit counter ke sebuah backend penyimpanan. Jika semua node aplikasi membaca dan menulis ke store yang sama, dengan key yang sama, terhadap identitas klien yang sama, maka perilakunya akan relatif konsisten. Ketika salah satu asumsi itu tidak terpenuhi, hasilnya menjadi tidak stabil.

Gejala yang sering terlihat

  • Request dari user yang sama lolos lebih banyak dari batas karena masing-masing node menghitung counter sendiri.
  • Request tiba-tiba cepat terkena limit karena semua client terlihat berasal dari satu IP proxy.
  • Node A dan node B memakai prefix key berbeda, sehingga counter tidak pernah benar-benar digabung.
  • Throttle konsisten di lingkungan lokal, tetapi kacau setelah lewat load balancer atau ingress controller.
  • Perilaku berbeda antara queue worker, Horizon, scheduler, dan HTTP app karena konfigurasi cache/store tidak seragam.

Akar masalah utamanya

Untuk mendiagnosis masalah, cek lima area ini terlebih dahulu:

  1. Sinkronisasi store: apakah semua node menulis ke backend yang sama untuk rate limit?
  2. Key prefix: apakah prefix cache/redis identik di semua node?
  3. Trust proxy: apakah Laravel percaya pada proxy yang benar sehingga IP client asli terbaca konsisten?
  4. Identitas user/IP: apakah key rate limit dibentuk dari identitas yang stabil?
  5. Perilaku Redis Cluster: apakah desain key cocok untuk cluster dan operasi yang digunakan?

1. Pastikan store throttle benar-benar shared antar node

Masalah paling dasar adalah throttle ternyata disimpan di tempat yang tidak dibagi. Misalnya satu node masih memakai array, satu lagi file, atau semua node memakai Redis tetapi ke database/index berbeda. Dalam kondisi ini, tiap node memiliki counter sendiri dan load balancer membuat request tersebar ke beberapa node, sehingga limit seolah longgar.

Apa yang perlu dicek

  • Nilai CACHE_STORE atau konfigurasi cache yang dipakai aplikasi.
  • Koneksi Redis yang dipakai untuk cache/throttle sama di semua pod/VM/container.
  • Environment variable benar-benar identik di semua node produksi.
  • Tidak ada fallback diam-diam ke store lain saat Redis bermasalah.

Contoh konfigurasi environment yang sebaiknya seragam:

APP_NAME=internal-api
CACHE_STORE=redis
REDIS_CLIENT=phpredis
REDIS_HOST=redis-cluster.service
REDIS_PASSWORD=null
REDIS_PORT=6379
CACHE_PREFIX=internal_api_prod

Jika Anda memakai store cache terpisah untuk alasan operasional, pastikan middleware throttle memang mengarah ke store yang sama di semua node. Jangan mengandalkan asumsi bahwa semua komponen otomatis memakai backend identik.

Catatan: masalah sinkronisasi store sering tidak terlihat pada trafik rendah. Begitu load balancer mulai membagi request secara lebih merata, ketidakkonsistenannya baru muncul.

2. Samakan key prefix agar semua node menulis key yang identik

Pada sistem multi-node, perbedaan kecil pada prefix dapat menyebabkan tiap node membuat namespace key yang berbeda. Akibatnya, request dari klien yang sama dihitung pada bucket yang berbeda-beda. Dari sisi user, throttle terasa “bocor”.

Penyebab umum prefix berbeda

  • APP_NAME berbeda antar node dan dipakai membentuk prefix default.
  • CACHE_PREFIX tidak di-set eksplisit.
  • Satu deployment memakai nama aplikasi lama, deployment lain nama baru.
  • Lingkungan staging dan production berbagi Redis tetapi prefix tidak disiplin.

Praktik yang lebih aman adalah menetapkan prefix eksplisit dan stabil, bukan membiarkannya terbentuk dari nilai yang bisa berubah:

APP_NAME=internal-api
CACHE_PREFIX=internal_api_prod

Jika Anda mengelola banyak service dalam satu cluster Redis, prefix juga membantu mencegah tabrakan key. Namun untuk rate limiter, prefix yang stabil lebih penting lagi karena ia menentukan apakah semua node menghitung terhadap bucket yang sama atau tidak.

Tips verifikasi cepat

Ambil satu request uji dari klien yang sama, lalu inspeksi key di Redis dari beberapa node aplikasi. Jika key yang terbentuk berbeda prefix atau pola namanya, maka akar masalah sudah terlihat. Hindari menebak-nebak; cek key aktual di environment yang bermasalah.

3. Trust proxy menentukan apakah IP client terbaca benar

Banyak implementasi throttle masih memakai IP sebagai bagian dari identitas request, terutama untuk endpoint publik atau endpoint internal tanpa autentikasi user. Di arsitektur modern, request biasanya melewati reverse proxy, load balancer, ingress, CDN, atau service mesh. Jika Laravel tidak dikonfigurasi untuk mempercayai proxy tersebut, method seperti $request->ip() bisa mengembalikan IP proxy, bukan IP client asli.

Akibatnya ada dua kemungkinan buruk:

  • Terlalu ketat: semua request terlihat berasal dari satu IP proxy yang sama, sehingga banyak klien berbagi bucket throttle yang sama.
  • Terlalu longgar atau tidak stabil: chain header proxy tidak dibaca konsisten, sehingga identitas IP berubah-ubah antar request.

Yang perlu dikonfigurasi

Pastikan middleware trust proxy di aplikasi sesuai dengan topologi jaringan Anda. Secara prinsip, Laravel harus mempercayai proxy yang memang berada di depan aplikasi agar header seperti X-Forwarded-For dipakai secara benar.

Di banyak deployment container atau cloud, konfigurasi yang umum adalah mempercayai proxy internal tertentu atau, bila memang sesuai dan aman dengan infrastruktur Anda, menggunakan wildcard untuk jaringan proxy yang terkontrol. Intinya bukan sekadar “aktifkan trust proxy”, tetapi sesuaikan dengan daftar proxy yang benar.

Kesalahan yang sering terjadi

  • Memakai $request->ip() untuk throttle tanpa pernah memverifikasi nilainya di production.
  • Menganggap header X-Forwarded-For selalu aman, padahal jika proxy trust salah, header itu bisa menyesatkan.
  • Berpindah dari satu load balancer ke ingress baru tanpa memperbarui konfigurasi trust proxy.

Langkah debugging yang sederhana adalah mencatat nilai berikut untuk beberapa request bermasalah: $request->ip(), isi header X-Forwarded-For, dan node aplikasi yang menangani request. Dari sana akan terlihat apakah identitas client stabil atau tidak.

4. Gunakan identitas rate limit yang stabil, bukan hanya IP

Untuk API internal, mengandalkan IP saja sering tidak ideal. Banyak service berjalan di balik NAT, gateway bersama, atau egress yang sama. Hasilnya, beberapa caller yang berbeda bisa berbagi satu IP dan saling mempengaruhi bucket throttle. Sebaliknya, pada sistem tertentu IP bisa berubah cukup sering sehingga limit terasa longgar.

Pendekatan yang lebih stabil adalah menyusun key dari identitas yang lebih bermakna, misalnya:

  • ID user terautentikasi.
  • API key atau client ID.
  • Service account name.
  • Kombinasi route penting + identitas caller.
  • Fallback ke IP hanya jika identitas utama tidak ada.

Contoh RateLimiter::for yang lebih stabil untuk API internal

Contoh berikut menggunakan urutan identitas: user ID, lalu header API key internal, lalu IP yang sudah melalui proxy trust. Tambahkan juga route sebagai bagian key agar endpoint sensitif tidak berbagi bucket dengan endpoint lain.

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('internal-api', function (Request $request) {
    $userId = $request->user()?->getAuthIdentifier();
    $apiKey = $request->header('X-Internal-Api-Key');
    $route = $request->route()?->getName() ?: $request->path();

    $identity = $userId
        ? "user:{$userId}"
        : ($apiKey
            ? 'key:' . hash('sha256', $apiKey)
            : 'ip:' . $request->ip());

    return [
        Limit::perMinute(300)->by("internal-api:{$route}:{$identity}"),
    ];
});

Mengapa pendekatan ini lebih stabil?

  • User ID lebih konsisten daripada IP untuk request terautentikasi.
  • API key di-hash sehingga tidak tersimpan mentah di key Redis.
  • Route dimasukkan ke key agar endpoint yang berbeda bisa dibatasi terpisah.
  • Fallback ke IP tetap tersedia untuk request anonim, selama trust proxy benar.

Jika Anda ingin satu bucket global per caller tanpa membedakan route, route bisa dihapus dari key. Pilihan ini tergantung kebutuhan proteksi. Untuk endpoint internal yang berat, bucket per route sering lebih masuk akal.

5. Pahami implikasi Redis Cluster terhadap rate limiter

Redis Cluster membagi key ke beberapa slot di beberapa node Redis. Untuk operasi sederhana per key, ini biasanya baik-baik saja. Tetapi desain key tetap penting, terutama jika ada operasi yang perlu konsisten pada key tertentu dan Anda ingin menghindari perilaku yang sulit diprediksi saat debugging.

Hal yang perlu diperhatikan

  • Rate limiter umumnya bekerja per key counter dengan TTL. Selama semua request untuk identitas yang sama membentuk key yang identik, cluster masih dapat melayani dengan baik.
  • Masalah muncul jika Anda tanpa sadar membentuk key yang berbeda-beda antar node aplikasi, lalu mengira penyebabnya Redis Cluster, padahal akar masalahnya identitas atau prefix.
  • Pada cluster, inspeksi manual menjadi lebih sulit karena key tersebar di beberapa shard. Ini bisa membuat diagnosis terasa membingungkan.

Gunakan key yang deterministik dan sederhana

Hindari komponen key yang berubah antar node, misalnya hostname container, nama pod, atau nilai environment yang tidak seragam. Untuk rate limit, key harus dibentuk hanya dari hal-hal yang memang mewakili caller dan endpoint.

Jika Anda punya kebutuhan lanjutan yang melibatkan beberapa key terkait dan ingin mereka berada pada slot yang sama, konsep hash tag Redis bisa relevan. Namun untuk implementasi throttle Laravel yang umum, fokus utamanya tetap pada key yang konsisten, bukan “memaksa” semua key ke shard tertentu tanpa alasan operasional yang jelas.

Kesalahan diagnosis yang sering terjadi

Ketika melihat hasil throttle berbeda-beda, tim sering langsung menyalahkan Redis Cluster. Padahal lebih sering penyebabnya adalah:

  • pod A memakai prefix berbeda dari pod B,
  • IP client terbaca sebagai IP ingress pada sebagian request,
  • caller anonim berbagi satu bucket karena NAT, atau
  • request terdistribusi ke beberapa node aplikasi yang tidak memakai store identik.

Checklist debugging yang praktis

Jika throttle Laravel Anda terasa tidak konsisten di multi-node, lakukan pengecekan berikut secara sistematis:

  1. Verifikasi store: pastikan semua node memakai backend Redis yang sama untuk cache/rate limit.
  2. Verifikasi prefix: cek CACHE_PREFIX dan nilai turunannya benar-benar sama.
  3. Log identitas limiter: catat string yang dipakai pada Limit::by(...) untuk request uji.
  4. Log IP dan proxy header: bandingkan $request->ip() dengan X-Forwarded-For.
  5. Uji lewat load balancer: jangan hanya menguji ke satu node secara langsung.
  6. Periksa route: pastikan route name/path yang dipakai dalam key tidak berubah tak terduga.
  7. Inspeksi Redis: cari key yang terbentuk dan lihat apakah pola key sesuai ekspektasi.

Untuk observabilitas, menambahkan log sementara pada titik pembentukan limiter sering jauh lebih efektif daripada menebak perilaku middleware dari respons 429 saja.

Trade-off dan rekomendasi implementasi

Kapan memakai IP?

IP masih berguna untuk endpoint anonim atau proteksi awal terhadap penyalahgunaan. Namun di sistem internal, IP sering terlalu kasar sebagai identitas utama. Gunakan IP sebagai fallback, bukan satu-satunya dasar pembatasan.

Kapan memakai user ID atau API key?

Jika caller sudah terautentikasi, identitas aplikasi atau user hampir selalu lebih baik. Ia lebih stabil, lebih adil, dan tidak terlalu terpengaruh topologi jaringan.

Apakah perlu sticky session?

Untuk rate limiter berbasis Redis shared, sticky session biasanya bukan solusi utama. Ia hanya menyamarkan masalah ketika store tidak sinkron atau identitas tidak konsisten. Solusi yang benar adalah memastikan semua node menghitung terhadap key yang sama pada backend shared yang sama.

Penutup

Rate limiter Laravel yang tidak konsisten di lingkungan multi-node hampir selalu berawal dari satu fakta sederhana: counter hanya akan akurat jika semua request yang seharusnya satu bucket benar-benar menulis ke store yang sama, dengan key yang sama, berdasarkan identitas yang sama. Karena itu, fokus utama Anda seharusnya bukan sekadar menaikkan atau menurunkan angka limit, melainkan memastikan fondasinya benar.

Mulailah dari store shared, tetapkan prefix yang stabil, benarkan trust proxy, pilih identitas caller yang masuk akal, lalu pahami bahwa Redis Cluster bukan penyebab ajaib dari semua ketidakkonsistenan. Dengan pendekatan ini, throttle API akan jauh lebih dapat diprediksi dan lebih adil untuk caller internal maupun eksternal.