Skip to content

Commit 5b08553

Browse files
committed
feat: Route remaining proxies through handle_store_error and document
Applies handle_store_error in RedisStoreProxy, RedisCacheStoreProxy and MemCacheStoreProxy so their calls participate in the shared bypass mechanism. Adds cross-proxy specs pinning each subclass default and documents bypassable_store_errors in the README.
1 parent a861023 commit 5b08553

5 files changed

Lines changed: 75 additions & 10 deletions

File tree

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,35 @@ Most applications should use a new, separate database used only for `rack-attack
315315

316316
Note that `Rack::Attack.cache` is only used for throttling, allow2ban and fail2ban filtering; not blocklisting and safelisting. Your cache store must implement `increment` and `write` like [ActiveSupport::Cache::Store](http://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html). This means that other cache stores which inherit from ActiveSupport::Cache::Store are also compatible. In-memory stores which are not backed by an external database, such as `ActiveSupport::Cache::MemoryStore.new`, will be mostly ineffective because each Ruby process in your deployment will have it's own state, effectively multiplying the number of requests each client can make by the number of Ruby processes you have deployed.
317317

318+
#### Bypassing store errors
319+
320+
By default, some store proxies will swallow the errors they historically rescued (`Redis::BaseConnectionError` for `Redis`, `Dalli::DalliError` for `Dalli`). When one of those errors is raised inside the proxy, the request goes through as if no throttling were applied, which keeps your app available if the dedicated rack-attack store goes down.
321+
322+
You can customize this behavior through `Rack::Attack.cache.bypassable_store_errors`:
323+
324+
```ruby
325+
# Use the proxy's built-in defaults (this is the default)
326+
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(url: "...")
327+
328+
# Bypass ALL errors raised by the store — requests continue serving even if the
329+
# store misbehaves in unexpected ways (e.g. Redis OOM, timeouts, protocol errors).
330+
Rack::Attack.cache.bypassable_store_errors = :all
331+
332+
# Bypass NO errors — any error from the store will propagate. This disables the
333+
# proxy's historical default rescue behavior as well.
334+
Rack::Attack.cache.bypassable_store_errors = :none
335+
336+
# Bypass a specific list of error classes (or class-name Strings). This REPLACES
337+
# the proxy's built-in defaults - include any you still want to rescue.
338+
Rack::Attack.cache.bypassable_store_errors = [
339+
Redis::BaseConnectionError,
340+
Redis::TimeoutError,
341+
"Redis::CommandError"
342+
]
343+
```
344+
345+
`bypassable_store_errors` can be set before or after assigning `cache.store`; the store is re-wrapped automatically.
346+
318347
## Customizing responses
319348

320349
Customize the response of blocklisted and throttled requests using an object that adheres to the [Rack app interface](http://www.rubydoc.info/github/rack/rack/file/SPEC.rdoc).

lib/rack/attack/store_proxy/mem_cache_store_proxy.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ def self.handle?(store)
1313
end
1414

1515
def read(name, options = {})
16-
super(name, options.merge!(raw: true))
16+
handle_store_error { super(name, options.merge!(raw: true)) }
1717
end
1818

1919
def write(name, value, options = {})
20-
super(name, value, options.merge!(raw: true))
20+
handle_store_error { super(name, value, options.merge!(raw: true)) }
2121
end
2222
end
2323
end

lib/rack/attack/store_proxy/redis_cache_store_proxy.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,25 @@ def increment(name, amount = 1, **options)
1717
# So in order to workaround this we use RedisCacheStore#write (which sets expiration) to initialize
1818
# the counter. After that we continue using the original RedisCacheStore#increment.
1919
if options[:expires_in] && !read(name)
20-
write(name, amount, options)
20+
handle_store_error { write(name, amount, options) }
2121

2222
amount
2323
else
24-
super
24+
handle_store_error { super }
2525
end
2626
end
2727
end
2828

2929
def read(name, options = {})
30-
super(name, options.merge!(raw: true))
30+
handle_store_error { super(name, options.merge!(raw: true)) }
3131
end
3232

3333
def write(name, value, options = {})
34-
super(name, value, options.merge!(raw: true))
34+
handle_store_error { super(name, value, options.merge!(raw: true)) }
3535
end
3636

3737
def delete_matched(matcher, options = nil)
38-
super(matcher.source, options)
38+
handle_store_error { super(matcher.source, options) }
3939
end
4040
end
4141
end

lib/rack/attack/store_proxy/redis_store_proxy.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ def self.handle?(store)
1111
end
1212

1313
def read(key)
14-
rescuing { get(key, raw: true) }
14+
handle_store_error { get(key, raw: true) }
1515
end
1616

1717
def write(key, value, options = {})
1818
if (expires_in = options[:expires_in])
19-
rescuing { setex(key, expires_in, value, raw: true) }
19+
handle_store_error { setex(key, expires_in, value, raw: true) }
2020
else
21-
rescuing { set(key, value, raw: true) }
21+
handle_store_error { set(key, value, raw: true) }
2222
end
2323
end
2424
end
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
require_relative 'spec_helper'
4+
5+
describe "Store proxy defaults (String-based)" do
6+
it "RedisStoreProxy inherits Redis default bypass list from RedisProxy" do
7+
skip 'redis gem not available' unless defined?(::Redis)
8+
_(Rack::Attack::StoreProxy::RedisStoreProxy.default_bypassable_store_errors)
9+
.must_equal ['Redis::BaseConnectionError']
10+
end
11+
12+
it "RedisCacheStoreProxy has no default bypass list (preserves upstream behavior)" do
13+
_(Rack::Attack::StoreProxy::RedisCacheStoreProxy.default_bypassable_store_errors).must_equal []
14+
end
15+
16+
it "MemCacheStoreProxy has no default bypass list (preserves upstream behavior)" do
17+
_(Rack::Attack::StoreProxy::MemCacheStoreProxy.default_bypassable_store_errors).must_equal []
18+
end
19+
20+
it "BaseProxy base default is an empty Array" do
21+
_(Rack::Attack::BaseProxy.default_bypassable_store_errors).must_equal []
22+
end
23+
24+
it "default list entries are Strings so missing gems don't break loading" do
25+
[
26+
Rack::Attack::StoreProxy::DalliProxy,
27+
Rack::Attack::StoreProxy::RedisProxy
28+
].each do |proxy_class|
29+
defaults = proxy_class.default_bypassable_store_errors
30+
_(defaults.all? { |e| e.is_a?(String) }).must_equal(
31+
true,
32+
"#{proxy_class}: expected all defaults to be Strings, got #{defaults.inspect}"
33+
)
34+
end
35+
end
36+
end

0 commit comments

Comments
 (0)