Mengapa Rate Limiter Redis Tidak Reset Setelah Retry

Bug rate limiter Redis yang tidak mereset setelah gagal bisa menghalangi request sah, terutama setelah job di-retry. Jika Anda melihat user mengalami blokir berulang meski setiap retry sebenarnya sah, masalahnya biasanya bukan di middleware tetapi pada counter Redis yang terus meningkat tanpa dikurangi. Dalam kasus ini, perlu segera pastikan counter kembali nol ketika proses gagal dan akan diulang.

Dalam studi kasus ini kita langsung menyelesaikan bug rate limiter Redis tak reset setelah event retry. Kami menelusuri gejala, log, root cause, serta menyiapkan perbaikan yang bisa diuji lokal dan dapat dilakukan rollback aman.

Gejala Nyata dan Inspeksi Log

Gejala pertama biasanya muncul sebagai respons 429 pada request yang seharusnya diperbolehkan karena sedang dalam retry pada queue job atau API idempotent. Laporan log menampilkan pesan seperti “Too Many Attempts” walau hanya satu request yang sedang diproses ulang.

Langkah inspeksi:

  • Periksa telemetri Redis: jumlah key rate_limit: untuk kombinasi IP/API. Jika counter tinggi walau tidak ada request baru, berarti tidak ada pengurangan.
  • Logging task queue: pastikan job yang gagal men-trigger release() atau retryUntil() tapi tidak menghapus counter.
  • Audit middleware rate limiter: apakah memanggil RateLimiter::hit() tanpa memastikan counter dihapus saat rollback/exception?

Monitoring berfungsi untuk memunculkan pola, misalnya counter naik sampai peak lalu tidak pernah turun setelah retry, sehingga sistem menolak request sah berikutnya.

Root Cause: Counter Tetap Tinggi dan Cache Shard Stagnan

Penyebab inti dalam kasus ini terbagi dua:

  1. Transaksi Laravel Gagal Tidak Mengurangi Rate Limit: Saat job queue gagal karena exception, callback atau event retry tidak menurunkan counter. Rate limiter default meningkatkan counter sebelum validasi selesai. Jika proses gagal, counter tetap dan tidak roll back karena bukan bagian dari transaksi database.
  2. Shard Cache Redis Tidak Terupdate: Sistem menggunakan cache shard untuk menyimpan counter per shard. Ketika shard mengalami timeout atau redis failover, counter tidak disinkronkan ulang, sehingga data usang menyebabkan pembacaan rate limit salah.

Salah satu log menunjukkan counter tetap di 200 walau request hanya satu, menandakan tidak ada mekanisme decrement saat error. Selain itu, fallback cache shard terisi data lama karena tidak ada mekanisme invalidasi saat redis reconnect.

Perbaikan Praktis

1. Gunakan Decrement Atomik dengan Lua Script

Laravel menyediakan RateLimiter::hit() dan RateLimiter::tooManyAttempts(), namun tidak secara otomatis mengurangi saat exception. Solusi: buat wrapper yang menjalankan hit dan siap decrement jika job ter-rollback, dengan bantuan skrip Lua agar operasi di Redis tetap atomik.

Contoh skrip sederhana:

local key = KEYS[1]
local decrement = tonumber(ARGV[1] or 1)
local ttl = tonumber(redis.call('ttl', key))
redis.call('decrby', key, decrement)
if ttl > 0 then
  redis.call('expire', key, ttl)
end

Implementasi di Laravel:

RateLimiter::hit($key);
try {
  // proses utama
} catch (Throwable $e) {
  Redis::eval($luaScript, 1, $key, 1);
  throw $e;
}

Dengan pendekatan ini, hit dan rollback counter menjadi operasional tunggal di Redis dan menghindari counter tidak konsisten.

2. Fallback Cache dan Sinkronisasi Shard

Untuk mengatasi data shard yang usang, siapkan fallback cache lokal (misalnya ArrayStore atau Persistent Cache di aplikasi) yang di-refresh saat deteksi redis failover. Hal ini membantu membaca counter terdekat tanpa tergantung sepenuhnya pada isi shard.

Monitoring shard bisa dilakukan lewat notification saat TTL rate limit terlalu besar atau ketika redis cluster memasuki read-only. Jika ada fallback, maka kita bisa menandai shard itu perlu resync setelah redis stabil kembali.

3. Monitoring dan Alert

Tambahkan metric baru pada observability stack:

  • Perubahan counter/timeline per key rate limiter.
  • Frekuensi 429 yang muncul selama retry.
  • Kesalahan Redis (timeout, socket exceptions) pada saat hit/decrement.

Alert bisa dikirimkan saat counter tidak turun 5 menit setelah hit atau saat retry backoff sudah di-reset namun request tetap dihalangi.

Verifikasi dan Rollback Aman

Untuk verifikasi lokal, jalankan scenario berikut:

  1. Simulasikan job queue yang selalu gagal pertama kali, lalu sukses setelah retry.
  2. Gunakan Redis::monitor() atau profiling untuk memastikan hit dan decrement dijalankan.
  3. Capai counter di atas threshold lalu lakukan retry. Pastikan counter turun setelah job gagal.

Untuk rollback aman:

  • Kemas perubahan dalam commit tunggal dan pastikan ada feature flag (misal rate_limiter_atomic) untuk menonaktifkan jika perlu.
  • Jangan langsung hapus counter atau script Lua di saat release kritis. Tambahkan versi script dan validasi bahwa script ter-cache di Redis.
  • Catat versi Redis dan cabang Laravel saat deploy supaya bisa kembali ke kombinasi stabil.

Kesimpulan

Bug rate limiter Redis yang tidak reset setelah event retry bisa menjadi sumber 429 yang sulit dilacak. Dengan menelusuri log, memahami pengaruh counter dan shard cache, lalu menerapkan atomic decrement + fallback, Anda mengembalikan kontrol terhadap rate limiter. Pantau lewat metric yang tepat dan siapkan rollback agar bisa kembali ke versi stabil jika perbaikan menunjukkan efek samping tak terduga.