Skip to content

Commit 728d610

Browse files
authored
Eagerly check for store validity (#714)
Ref: #315 `respond_to?` is a relatively expensive method that doesn't benefit from inline caching, so it has to keep looking up the method every time. Hence it's best to avoid it in hotspots. In this case, `@store` can only change through the `store=` method, so we might as well eagerly validate it there. First because this way it's a one time fixed cost, but also because this way the error is raised early during boot/configuration rather than at runtime where it has availability impact.
1 parent 6c16764 commit 728d610

5 files changed

Lines changed: 60 additions & 141 deletions

File tree

lib/rack/attack/cache.rb

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ def store=(store)
2626
else
2727
store
2828
end
29+
if @store
30+
check_store_methods_presence(:read, :write, :delete, :increment)
31+
end
2932
end
3033

3134
def count(unprefixed_key, period)
@@ -34,13 +37,14 @@ def count(unprefixed_key, period)
3437
end
3538

3639
def read(unprefixed_key)
37-
enforce_store_presence!
38-
enforce_store_method_presence!(:read)
40+
raise Rack::Attack::MissingStoreError if store.nil?
3941

4042
store.read("#{prefix}:#{unprefixed_key}")
4143
end
4244

4345
def write(unprefixed_key, value, expires_in)
46+
raise Rack::Attack::MissingStoreError if store.nil?
47+
4448
store.write("#{prefix}:#{unprefixed_key}", value, expires_in: expires_in)
4549
end
4650

@@ -74,32 +78,22 @@ def key_and_expiry(unprefixed_key, period)
7478
end
7579

7680
def do_count(key, expires_in)
77-
enforce_store_presence!
78-
enforce_store_method_presence!(:increment)
81+
raise Rack::Attack::MissingStoreError if store.nil?
7982

8083
result = store.increment(key, 1, expires_in: expires_in)
8184

8285
# NB: Some stores return nil when incrementing uninitialized values
8386
if result.nil?
84-
enforce_store_method_presence!(:write)
85-
8687
store.write(key, 1, expires_in: expires_in)
8788
end
8889
result || 1
8990
end
9091

91-
def enforce_store_presence!
92-
if store.nil?
93-
raise Rack::Attack::MissingStoreError
94-
end
95-
end
96-
97-
def enforce_store_method_presence!(method_name)
98-
if !store.respond_to?(method_name)
99-
raise(
100-
Rack::Attack::MisconfiguredStoreError,
101-
"Configured store #{store.class.name} doesn't respond to ##{method_name} method"
102-
)
92+
def check_store_methods_presence(*method_names)
93+
missing = method_names.reject { |m| store.respond_to?(m) }
94+
unless missing.empty?
95+
missing = missing.map { |m| "##{m}" }.join(", ")
96+
warn "[rack-attack] Configured store #{store.class.name} doesn't respond to #{missing}"
10397
end
10498
end
10599
end

spec/acceptance/cache_store_config_for_allow2ban_spec.rb

Lines changed: 9 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -20,67 +20,14 @@
2020
end
2121
end
2222

23-
it "gives semantic error if store is missing #read method" do
24-
raised_exception = nil
25-
26-
fake_store_class = Class.new do
27-
def write(key, value); end
28-
29-
def increment(key, count, options = {}); end
23+
it "display warning if store is missing methods" do
24+
warning = "[rack-attack] Configured store Object doesn't respond to #read, #write, #delete, #increment\n"
25+
assert_output("", warning) do
26+
Rack::Attack.cache.store = Object.new
3027
end
31-
32-
Object.stub_const(:FakeStore, fake_store_class) do
33-
Rack::Attack.cache.store = FakeStore.new
34-
35-
raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do
36-
get "/scarce-resource"
37-
end
38-
end
39-
40-
assert_equal "Configured store FakeStore doesn't respond to #read method", raised_exception.message
4128
end
4229

43-
it "gives semantic error if store is missing #write method" do
44-
raised_exception = nil
45-
46-
fake_store_class = Class.new do
47-
def read(key); end
48-
49-
def increment(key, count, options = {}); end
50-
end
51-
52-
Object.stub_const(:FakeStore, fake_store_class) do
53-
Rack::Attack.cache.store = FakeStore.new
54-
55-
raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do
56-
get "/scarce-resource"
57-
end
58-
end
59-
60-
assert_equal "Configured store FakeStore doesn't respond to #write method", raised_exception.message
61-
end
62-
63-
it "gives semantic error if store is missing #increment method" do
64-
raised_exception = nil
65-
66-
fake_store_class = Class.new do
67-
def read(key); end
68-
69-
def write(key, value); end
70-
end
71-
72-
Object.stub_const(:FakeStore, fake_store_class) do
73-
Rack::Attack.cache.store = FakeStore.new
74-
75-
raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do
76-
get "/scarce-resource"
77-
end
78-
end
79-
80-
assert_equal "Configured store FakeStore doesn't respond to #increment method", raised_exception.message
81-
end
82-
83-
it "works with any object that responds to #read, #write and #increment" do
30+
it "works with any object that responds to #read, #write, #delete and #increment" do
8431
fake_store_class = Class.new do
8532
attr_accessor :backend
8633

@@ -100,6 +47,10 @@ def increment(key, _count, _options = {})
10047
@backend[key] ||= 0
10148
@backend[key] += 1
10249
end
50+
51+
def delete(key)
52+
@backend.delete(key)
53+
end
10354
end
10455

10556
Object.stub_const(:FakeStore, fake_store_class) do

spec/acceptance/cache_store_config_for_fail2ban_spec.rb

Lines changed: 9 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -20,67 +20,14 @@
2020
end
2121
end
2222

23-
it "gives semantic error if store is missing #read method" do
24-
raised_exception = nil
25-
26-
fake_store_class = Class.new do
27-
def write(key, value); end
28-
29-
def increment(key, count, options = {}); end
23+
it "display warning if store is missing methods" do
24+
warning = "[rack-attack] Configured store Object doesn't respond to #read, #write, #delete, #increment\n"
25+
assert_output("", warning) do
26+
Rack::Attack.cache.store = Object.new
3027
end
31-
32-
Object.stub_const(:FakeStore, fake_store_class) do
33-
Rack::Attack.cache.store = FakeStore.new
34-
35-
raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do
36-
get "/private-place"
37-
end
38-
end
39-
40-
assert_equal "Configured store FakeStore doesn't respond to #read method", raised_exception.message
4128
end
4229

43-
it "gives semantic error if store is missing #write method" do
44-
raised_exception = nil
45-
46-
fake_store_class = Class.new do
47-
def read(key); end
48-
49-
def increment(key, count, options = {}); end
50-
end
51-
52-
Object.stub_const(:FakeStore, fake_store_class) do
53-
Rack::Attack.cache.store = FakeStore.new
54-
55-
raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do
56-
get "/private-place"
57-
end
58-
end
59-
60-
assert_equal "Configured store FakeStore doesn't respond to #write method", raised_exception.message
61-
end
62-
63-
it "gives semantic error if store is missing #increment method" do
64-
raised_exception = nil
65-
66-
fake_store_class = Class.new do
67-
def read(key); end
68-
69-
def write(key, value); end
70-
end
71-
72-
Object.stub_const(:FakeStore, fake_store_class) do
73-
Rack::Attack.cache.store = FakeStore.new
74-
75-
raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do
76-
get "/private-place"
77-
end
78-
end
79-
80-
assert_equal "Configured store FakeStore doesn't respond to #increment method", raised_exception.message
81-
end
82-
83-
it "works with any object that responds to #read, #write and #increment" do
30+
it "works with any object that responds to #read, #write, #delete and #increment" do
8431
fake_store_class = Class.new do
8532
attr_accessor :backend
8633

@@ -100,6 +47,10 @@ def increment(key, _count, _options = {})
10047
@backend[key] ||= 0
10148
@backend[key] += 1
10249
end
50+
51+
def delete(key)
52+
@backend.delete(key)
53+
end
10354
end
10455

10556
Rack::Attack.cache.store = fake_store_class.new

spec/acceptance/cache_store_config_for_throttle_spec.rb

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,9 @@
1818
end
1919

2020
it "gives semantic error if incompatible store was configured" do
21-
Rack::Attack.cache.store = Object.new
22-
23-
assert_raises(Rack::Attack::MisconfiguredStoreError) do
24-
get "/", {}, "REMOTE_ADDR" => "1.2.3.4"
21+
warning = "[rack-attack] Configured store Object doesn't respond to #read, #write, #delete, #increment\n"
22+
assert_output("", warning) do
23+
Rack::Attack.cache.store = Object.new
2524
end
2625
end
2726

@@ -33,10 +32,22 @@ def initialize
3332
@counts = {}
3433
end
3534

35+
def read(key)
36+
@counts[key]
37+
end
38+
39+
def write(key, count)
40+
@counts[key] = count
41+
end
42+
3643
def increment(key, _count, _options)
3744
@counts[key] ||= 0
3845
@counts[key] += 1
3946
end
47+
48+
def delete(key)
49+
@counts.delete(key)
50+
end
4051
end
4152

4253
Rack::Attack.cache.store = basic_store_class.new

spec/rack_attack_reset_spec.rb

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,21 @@
44

55
describe "Rack::Attack.reset!" do
66
it "raises an error when is not supported by cache store" do
7-
Rack::Attack.cache.store = Class.new
8-
assert_raises(Rack::Attack::IncompatibleStoreError) do
9-
Rack::Attack.reset!
7+
fake_store_class = Class.new do
8+
def read(key); end
9+
10+
def write(key, value); end
11+
12+
def increment(key, count, options = {}); end
13+
14+
def delete(key); end
15+
end
16+
17+
Object.stub_const(:FakeStore, fake_store_class) do
18+
Rack::Attack.cache.store = FakeStore.new
19+
assert_raises(Rack::Attack::IncompatibleStoreError) do
20+
Rack::Attack.reset!
21+
end
1022
end
1123
end
1224

0 commit comments

Comments
 (0)