Skip to content

Commit a861023

Browse files
committed
feat: Route RedisProxy errors through handle_store_error
Replaces the bespoke rescuing helper with handle_store_error and declares ['Redis::BaseConnectionError'] as the default bypass list.
1 parent 75e40da commit a861023

2 files changed

Lines changed: 147 additions & 16 deletions

File tree

lib/rack/attack/store_proxy/redis_proxy.rb

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,32 +6,36 @@ module Rack
66
class Attack
77
module StoreProxy
88
class RedisProxy < BaseProxy
9-
def initialize(*args)
9+
def initialize(store, **options)
1010
if Gem::Version.new(Redis::VERSION) < Gem::Version.new("3")
1111
warn 'RackAttack requires Redis gem >= 3.0.0.'
1212
end
1313

14-
super(*args)
14+
super(store, **options)
1515
end
1616

1717
def self.handle?(store)
1818
defined?(::Redis) && store.class == ::Redis
1919
end
2020

21+
def self.default_bypassable_store_errors
22+
['Redis::BaseConnectionError']
23+
end
24+
2125
def read(key)
22-
rescuing { get(key) }
26+
handle_store_error { get(key) }
2327
end
2428

2529
def write(key, value, options = {})
2630
if (expires_in = options[:expires_in])
27-
rescuing { setex(key, expires_in, value) }
31+
handle_store_error { setex(key, expires_in, value) }
2832
else
29-
rescuing { set(key, value) }
33+
handle_store_error { set(key, value) }
3034
end
3135
end
3236

3337
def increment(key, amount, options = {})
34-
rescuing do
38+
handle_store_error do
3539
pipelined do |redis|
3640
redis.incrby(key, amount)
3741
redis.expire(key, options[:expires_in]) if options[:expires_in]
@@ -40,14 +44,14 @@ def increment(key, amount, options = {})
4044
end
4145

4246
def delete(key, _options = {})
43-
rescuing { del(key) }
47+
handle_store_error { del(key) }
4448
end
4549

4650
def delete_matched(matcher, _options = nil)
4751
cursor = "0"
4852
source = matcher.source
4953

50-
rescuing do
54+
handle_store_error do
5155
# Fetch keys in batches using SCAN to avoid blocking the Redis server.
5256
loop do
5357
cursor, keys = scan(cursor, match: source, count: 1000)
@@ -56,14 +60,6 @@ def delete_matched(matcher, _options = nil)
5660
end
5761
end
5862
end
59-
60-
private
61-
62-
def rescuing
63-
yield
64-
rescue Redis::BaseConnectionError
65-
nil
66-
end
6763
end
6864
end
6965
end
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# frozen_string_literal: true
2+
3+
require_relative 'spec_helper'
4+
5+
module RedisProxySpec
6+
class FakeRedis
7+
class Error < StandardError; end
8+
9+
attr_reader :calls
10+
11+
def initialize(raises: nil)
12+
@raises = raises
13+
@calls = []
14+
end
15+
16+
def get(key)
17+
record(:get, key)
18+
end
19+
20+
def set(key, value)
21+
record(:set, [key, value])
22+
end
23+
24+
def setex(key, expires_in, value)
25+
record(:setex, [key, expires_in, value])
26+
end
27+
28+
def del(*keys)
29+
record(:del, keys)
30+
end
31+
32+
def pipelined
33+
record(:pipelined)
34+
yield(self) if block_given?
35+
[1]
36+
end
37+
38+
def incrby(key, amount)
39+
record(:incrby, [key, amount])
40+
end
41+
42+
def expire(key, ttl)
43+
record(:expire, [key, ttl])
44+
end
45+
46+
def scan(_cursor, **_opts)
47+
["0", []]
48+
end
49+
50+
def record(method, args = nil)
51+
@calls << [method, args]
52+
raise @raises if @raises
53+
54+
:ok
55+
end
56+
end
57+
end
58+
59+
describe Rack::Attack::StoreProxy::RedisProxy do
60+
before do
61+
skip 'redis gem not available' unless defined?(::Redis)
62+
end
63+
64+
it "declares its default as ['Redis::BaseConnectionError']" do
65+
_(Rack::Attack::StoreProxy::RedisProxy.default_bypassable_store_errors)
66+
.must_equal ['Redis::BaseConnectionError']
67+
end
68+
69+
it "bypasses Redis::BaseConnectionError by default on #read" do
70+
redis = RedisProxySpec::FakeRedis.new(raises: Redis::BaseConnectionError.new("down"))
71+
proxy = Rack::Attack::StoreProxy::RedisProxy.new(redis)
72+
_(proxy.read("k")).must_be_nil
73+
end
74+
75+
it "bypasses Redis::BaseConnectionError subclasses on #write" do
76+
redis = RedisProxySpec::FakeRedis.new(raises: Redis::CannotConnectError.new("down"))
77+
proxy = Rack::Attack::StoreProxy::RedisProxy.new(redis)
78+
_(proxy.write("k", "v")).must_be_nil
79+
end
80+
81+
it "re-raises other errors by default" do
82+
redis = RedisProxySpec::FakeRedis.new(raises: RuntimeError.new("boom"))
83+
proxy = Rack::Attack::StoreProxy::RedisProxy.new(redis)
84+
_(-> { proxy.read("k") }).must_raise RuntimeError
85+
end
86+
87+
it "bypasses custom errors when configured" do
88+
redis = RedisProxySpec::FakeRedis.new(raises: RedisProxySpec::FakeRedis::Error.new("oom"))
89+
proxy = Rack::Attack::StoreProxy::RedisProxy.new(
90+
redis,
91+
bypassable_store_errors: [RedisProxySpec::FakeRedis::Error]
92+
)
93+
_(proxy.read("k")).must_be_nil
94+
end
95+
96+
it "bypasses all errors when configured with :all" do
97+
redis = RedisProxySpec::FakeRedis.new(raises: RuntimeError.new("anything"))
98+
proxy = Rack::Attack::StoreProxy::RedisProxy.new(redis, bypassable_store_errors: :all)
99+
_(proxy.read("k")).must_be_nil
100+
end
101+
102+
it "re-raises default errors when configured with :none" do
103+
redis = RedisProxySpec::FakeRedis.new(raises: Redis::BaseConnectionError.new("down"))
104+
proxy = Rack::Attack::StoreProxy::RedisProxy.new(redis, bypassable_store_errors: :none)
105+
_(-> { proxy.read("k") }).must_raise Redis::BaseConnectionError
106+
end
107+
108+
it "user-provided Array overrides the default (does not merge)" do
109+
redis = RedisProxySpec::FakeRedis.new(raises: Redis::BaseConnectionError.new("down"))
110+
proxy = Rack::Attack::StoreProxy::RedisProxy.new(
111+
redis,
112+
bypassable_store_errors: [RedisProxySpec::FakeRedis::Error]
113+
)
114+
_(-> { proxy.read("k") }).must_raise Redis::BaseConnectionError
115+
end
116+
117+
it "returns nil from #increment on a matching error" do
118+
redis = RedisProxySpec::FakeRedis.new(raises: Redis::BaseConnectionError.new("down"))
119+
proxy = Rack::Attack::StoreProxy::RedisProxy.new(redis)
120+
_(proxy.increment("k", 1, expires_in: 60)).must_be_nil
121+
end
122+
123+
it "returns nil from #delete on a matching error" do
124+
redis = RedisProxySpec::FakeRedis.new(raises: Redis::BaseConnectionError.new("down"))
125+
proxy = Rack::Attack::StoreProxy::RedisProxy.new(redis)
126+
_(proxy.delete("k")).must_be_nil
127+
end
128+
129+
it "raises MisconfiguredStoreError for invalid config" do
130+
redis = RedisProxySpec::FakeRedis.new
131+
_(-> {
132+
Rack::Attack::StoreProxy::RedisProxy.new(redis, bypassable_store_errors: :sometimes)
133+
}).must_raise Rack::Attack::MisconfiguredStoreError
134+
end
135+
end

0 commit comments

Comments
 (0)