rate limit: prune stale keys so @hits cannot grow unbounded

This commit is contained in:
Sergei Poljanski 2026-07-02 17:31:19 +04:00
commit a6a155ee46
Signed by: asxpi
GPG key ID: 4F8851660FA4121B

View file

@ -1,16 +1,22 @@
require 'thread' require 'thread'
class RateLimit class RateLimit
# Every Nth allow? call also drops empty keys, so IPs that stop posting
# don't accumulate in @hits forever.
SWEEP_EVERY = 100
def initialize(limit:, window:) def initialize(limit:, window:)
@limit = limit @limit = limit
@window = window @window = window
@hits = Hash.new { |h, k| h[k] = [] } @hits = Hash.new { |h, k| h[k] = [] }
@mutex = Mutex.new @mutex = Mutex.new
@calls = 0
end end
def allow?(key) def allow?(key)
now = Time.now.to_i now = Time.now.to_i
@mutex.synchronize do @mutex.synchronize do
prune(now - @window) if (@calls += 1) % SWEEP_EVERY == 0
@hits[key].reject! { |t| t < now - @window } @hits[key].reject! { |t| t < now - @window }
if @hits[key].size >= @limit if @hits[key].size >= @limit
false false
@ -22,10 +28,14 @@ class RateLimit
end end
def sweep! def sweep!
cutoff = Time.now.to_i - @window @mutex.synchronize { prune(Time.now.to_i - @window) }
@mutex.synchronize do end
private
# Caller must hold @mutex.
def prune(cutoff)
@hits.each_value { |arr| arr.reject! { |t| t < cutoff } } @hits.each_value { |arr| arr.reject! { |t| t < cutoff } }
@hits.delete_if { |_, arr| arr.empty? } @hits.delete_if { |_, arr| arr.empty? }
end end
end
end end