From 003c9e7bf457e3dfe66c8df42dee4d95234e6607 Mon Sep 17 00:00:00 2001 From: Krzysztof Adamski Date: Sun, 15 Mar 2026 22:20:27 +0100 Subject: [PATCH] Support cache store without delete_matched --- lib/rack/attack/cache.rb | 46 +++++++++++++++++++++++++--------- spec/rack_attack_reset_spec.rb | 40 +++++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 17 deletions(-) diff --git a/lib/rack/attack/cache.rb b/lib/rack/attack/cache.rb index 9111ab8a..5fe3deb9 100644 --- a/lib/rack/attack/cache.rb +++ b/lib/rack/attack/cache.rb @@ -15,6 +15,7 @@ def self.default_store def initialize(store: self.class.default_store) self.store = store @prefix = 'rack::attack' + @generation = nil end attr_reader :store @@ -37,11 +38,11 @@ def read(unprefixed_key) enforce_store_presence! enforce_store_method_presence!(:read) - store.read("#{prefix}:#{unprefixed_key}") + store.read("#{effective_prefix}:#{unprefixed_key}") end def write(unprefixed_key, value, expires_in) - store.write("#{prefix}:#{unprefixed_key}", value, expires_in: expires_in) + store.write("#{effective_prefix}:#{unprefixed_key}", value, expires_in: expires_in) end def reset_count(unprefixed_key, period) @@ -50,27 +51,48 @@ def reset_count(unprefixed_key, period) end def delete(unprefixed_key) - store.delete("#{prefix}:#{unprefixed_key}") + store.delete("#{effective_prefix}:#{unprefixed_key}") end def reset! - if store.respond_to?(:delete_matched) - store.delete_matched(/#{prefix}*/) - else - raise( - Rack::Attack::IncompatibleStoreError, - "Configured store #{store.class.name} doesn't respond to #delete_matched method" - ) - end + store.delete_matched(/#{effective_prefix}*/) + rescue NoMethodError, NotImplementedError + rotate_generation! end private + def generation_key + "#{@prefix}:generation" + end + + def current_generation + if @generation.nil? + @generation = if store.nil? || !store.respond_to?(:read) + 0 + else + store.read(generation_key).to_i + end + end + @generation + end + + def effective_prefix + gen = current_generation + gen > 0 ? "#{@prefix}:g#{gen}" : @prefix + end + + def rotate_generation! + new_gen = current_generation + 1 + store.write(generation_key, new_gen, expires_in: nil) + @generation = new_gen + end + def key_and_expiry(unprefixed_key, period) @last_epoch_time = Time.now.to_i # Add 1 to expires_in to avoid timing error: https://github.com/rack/rack-attack/pull/85 expires_in = (period - (@last_epoch_time % period) + 1).to_i - ["#{prefix}:#{(@last_epoch_time / period).to_i}:#{unprefixed_key}", expires_in] + ["#{effective_prefix}:#{(@last_epoch_time / period).to_i}:#{unprefixed_key}", expires_in] end def do_count(key, expires_in) diff --git a/spec/rack_attack_reset_spec.rb b/spec/rack_attack_reset_spec.rb index b9a94e39..c7ff3e6f 100644 --- a/spec/rack_attack_reset_spec.rb +++ b/spec/rack_attack_reset_spec.rb @@ -3,11 +3,41 @@ require_relative "spec_helper" describe "Rack::Attack.reset!" do - it "raises an error when is not supported by cache store" do - Rack::Attack.cache.store = Class.new - assert_raises(Rack::Attack::IncompatibleStoreError) do - Rack::Attack.reset! - end + it "falls back to prefix rotation when delete_matched raises NotImplementedError" do + store = Class.new do + def initialize + @data = {} + end + + def read(key) + @data[key] + end + + def write(key, value, _options = {}) + @data[key] = value + end + + def increment(key, amount, _options = {}) + @data[key] = (@data[key] || 0) + amount + end + + def delete(key) + @data.delete(key) + end + + def delete_matched(_matcher, _options = nil) + raise NotImplementedError + end + end.new + + Rack::Attack.cache.store = store + + Rack::Attack.cache.write("test-key", "value", 300) + _(Rack::Attack.cache.read("test-key")).must_equal "value" + + Rack::Attack.reset! + + _(Rack::Attack.cache.read("test-key")).must_be_nil end if defined?(Redis)