From e75dc076e568b2e8f415c64cc92824f20f542bbf Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 21:08:16 +0200 Subject: [PATCH] feat(rate-limiter): add Prism and RateLimiter --- lib/prism/rate_limiter/memory_store.rb | 57 +++++++++++++++++++++++ lib/prism/rate_limiter/pstore_store.rb | 62 ++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 lib/prism/rate_limiter/memory_store.rb create mode 100644 lib/prism/rate_limiter/pstore_store.rb diff --git a/lib/prism/rate_limiter/memory_store.rb b/lib/prism/rate_limiter/memory_store.rb new file mode 100644 index 0000000..7ac1ac5 --- /dev/null +++ b/lib/prism/rate_limiter/memory_store.rb @@ -0,0 +1,57 @@ +require 'monitor' + +module Prism + module RateLimiter + # In-process fixed-window counter + ban list, guarded by a Monitor so + # concurrent requests (threaded server, parallel test threads) do not + # race on the same key. Lost on process restart - use PStoreStore when + # limits need to survive that. + class MemoryStore + include MonitorMixin + + Window = Struct.new(:started_at, :count) + + def initialize + super() + @windows = {} + @bans = {} + end + + def count(key, period) + synchronize do + now = Time.now + window = @windows[key] + if window.nil? || now - window.started_at >= period + window = Window.new(now, 0) + @windows[key] = window + end + window.count += 1 + end + end + + def banned?(key) + synchronize do + expires_at = @bans[key] + next false unless expires_at + if Time.now >= expires_at + @bans.delete(key) + false + else + true + end + end + end + + def ban!(key, duration) + synchronize { @bans[key] = Time.now + duration } + end + + def reset! + synchronize do + @windows.clear + @bans.clear + end + end + end + end +end diff --git a/lib/prism/rate_limiter/pstore_store.rb b/lib/prism/rate_limiter/pstore_store.rb new file mode 100644 index 0000000..f2bf34e --- /dev/null +++ b/lib/prism/rate_limiter/pstore_store.rb @@ -0,0 +1,62 @@ +require 'pstore' +require 'fileutils' + +module Prism + module RateLimiter + # PStore-backed fixed-window counter + ban list, same contract as + # MemoryStore, but durable: limits and bans survive a process + # restart, and multiple processes sharing one file share state + # (each PStore#transaction takes an exclusive flock on the file). + class PStoreStore + def initialize(path) + @path = path + directory = File.dirname(path) + FileUtils.mkdir_p(directory) unless directory == '.' || Dir.exist?(directory) + @pstore = PStore.new(path) + @pstore.transaction do + @pstore[:windows] ||= {} + @pstore[:bans] ||= {} + end + end + + def count(key, period) + @pstore.transaction do + now = Time.now + started_at, current = @pstore[:windows][key] + if started_at.nil? || now - started_at >= period + started_at = now + current = 0 + end + current += 1 + @pstore[:windows][key] = [started_at, current] + current + end + end + + def banned?(key) + @pstore.transaction do + expires_at = @pstore[:bans][key] + if expires_at.nil? + false + elsif Time.now >= expires_at + @pstore[:bans].delete(key) + false + else + true + end + end + end + + def ban!(key, duration) + @pstore.transaction { @pstore[:bans][key] = Time.now + duration } + end + + def reset! + @pstore.transaction do + @pstore[:windows] = {} + @pstore[:bans] = {} + end + end + end + end +end