Skip to content

Commit ae9d7fd

Browse files
committed
This PR adds several features and changes to error handling:
- Catch and handle all errors once per request. - Remove the `rescuing` blocks from the store proxies; rescuing per-method (read, write, increment) is bad because (a) it may result in undefined behavior, and (b) it will trigger repeated connection timeouts if your cache is down, e.g. N * M * timeout latency where N is the number of Rack::Attack metrics and M is the cache requests per metric. - Add `Rack::Attack.ignored_errors` config. This defaults to Dalli::DalliError and Redis::BaseError. - Add `Rack::Attack.failure_cooldown` config. This temporarily disables Rack::Attack after an error occurs (including ignored errors), to prevent cache connection latency. The default is 60 seconds. - Add `Rack::Attack.error_handler` which takes a Proc for custom error handling. It's probably not needed but there may be esoteric use cases for it. You can also use the shortcut symbols :block, :throttle, and :allow to respond to errors using those. - Add `Rack::Attack.calling?` method which uses Thread.current (or RequestStore, if available) to indicate that Rack::Attack code is executing. The reason for this is to add custom error handlers in the Rails Cache, i.e. "raise the error if it occurred while Rack::Attack was executing, so that Rack::Attack and handle it." Refer to readme. - Add "Fault Tolerance & Error Handling" section to Readme which includes all of the above.
1 parent 933c057 commit ae9d7fd

10 files changed

+659
-68
lines changed

README.md

+103
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ See the [Backing & Hacking blog post](https://www.kickstarter.com/backing-and-ha
3737
- [Customizing responses](#customizing-responses)
3838
- [RateLimit headers for well-behaved clients](#ratelimit-headers-for-well-behaved-clients)
3939
- [Logging & Instrumentation](#logging--instrumentation)
40+
- [Fault Tolerance & Error Handling](#fault-tolerance--error-handling)
41+
- [Expose Rails cache errors to Rack::Attack](#expose-rails-cache-errors-to-rackattack)
42+
- [Configure cache timeout](#configure-cache-timeout)
43+
- [Failure cooldown](#failure-cooldown)
44+
- [Custom error handling](#custom-error-handling)
4045
- [Testing](#testing)
4146
- [How it works](#how-it-works)
4247
- [About Tracks](#about-tracks)
@@ -395,6 +400,104 @@ ActiveSupport::Notifications.subscribe(/rack_attack/) do |name, start, finish, r
395400
end
396401
```
397402

403+
## Fault Tolerance & Error Handling
404+
405+
Rack::Attack has a mission-critical dependency on your [cache store](#cache-store-configuration).
406+
If the cache system experiences an outage, it may cause severe latency within Rack::Attack
407+
and lead to an overall application outage.
408+
409+
This section explains how to configure your application and handle errors in order to mitigate issues.
410+
411+
### Expose Rails cache errors to Rack::Attack
412+
413+
If using Rails cache, by default, Rails cache will suppress any errors raised by the underlying cache store.
414+
You'll need to expose these errors to Rack::Attack with a custom error handler follows:
415+
416+
```ruby
417+
# in your Rails config
418+
config.cache_store = :redis_cache_store,
419+
{ # ...
420+
error_handler: -> (method:, returning:, exception:) do
421+
raise exception if Rack::Attack.calling?
422+
end }
423+
```
424+
425+
By default, if a Redis or Dalli cache error occurs, Rack::Attack will ignore the error and allow the request.
426+
427+
### Configure cache timeout
428+
429+
In your application config, it is recommended to set your cache timeout to 0.1 seconds or lower.
430+
Please refer to the [Rails Guide](https://guides.rubyonrails.org/caching_with_rails.html).
431+
432+
```ruby
433+
# Set 100 millisecond timeout on Redis
434+
config.cache_store = :redis_cache_store,
435+
{ # ...
436+
connect_timeout: 0.1,
437+
read_timeout: 0.1,
438+
write_timeout: 0.1 }
439+
```
440+
441+
To use different timeout values specific to Rack::Attack, you may set a
442+
[Rack::Attack-specific cache configuration](#cache-store-configuration).
443+
444+
### Failure cooldown
445+
446+
When any error occurs, Rack::Attack becomes disabled for a 60 seconds "cooldown" period.
447+
This prevents a cache outage from adding timeout latency on each Rack::Attack request.
448+
You can configure the cooldown period as follows:
449+
450+
```ruby
451+
# in initializers/rack_attack.rb
452+
453+
# Disable Rack::Attack for 5 minutes if any cache failure occurs
454+
Rack::Attack.failure_cooldown = 300
455+
456+
# Do not use failure cooldown
457+
Rack::Attack.failure_cooldown = nil
458+
```
459+
460+
### Custom error handling
461+
462+
By default, Rack::Attack will ignore any Redis or Dalli cache errors, and raise any other errors it receives.
463+
Note that ignored errors will still trigger the failure cooldown. Ignored errors may be specified as Class
464+
or String values.
465+
466+
```ruby
467+
# in initializers/rack_attack.rb
468+
Rack::Attack.ignored_errors += [MyErrorClass, 'MyOtherErrorClass']
469+
```
470+
471+
Alternatively, you may define a custom error handler as a Proc. The error handler will receive all errors,
472+
regardless of whether they are on the ignore list. Your handler should return either `:allow`, `:block`,
473+
or `:throttle`, or else re-raise the error; other returned values will allow the request.
474+
475+
```ruby
476+
# Set a custom error handler which blocks ignored errors
477+
# and raises all others
478+
Rack::Attack.error_handler = -> (error) do
479+
if Rack::Attack.ignored_error?(error)
480+
Rails.logger.warn("Blocking error: #{error}")
481+
:block
482+
else
483+
raise(error)
484+
end
485+
end
486+
```
487+
488+
Lastly, you can define the error handlers as a Symbol shortcut:
489+
490+
```ruby
491+
# Handle all errors with block response
492+
Rack::Attack.error_handler = :block
493+
494+
# Handle all errors with throttle response
495+
Rack::Attack.error_handler = :throttle
496+
497+
# Handle all errors by allowing the request
498+
Rack::Attack.error_handler = :allow
499+
```
500+
398501
## Testing
399502

400503
A note on developing and testing apps using Rack::Attack - if you are using throttling in particular, you will

lib/rack/attack.rb

+120-18
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,18 @@ class IncompatibleStoreError < Error; end
3030
autoload :Fail2Ban, 'rack/attack/fail2ban'
3131
autoload :Allow2Ban, 'rack/attack/allow2ban'
3232

33+
THREAD_CALLING_KEY = 'rack.attack.calling'
34+
DEFAULT_FAILURE_COOLDOWN = 60
35+
DEFAULT_IGNORED_ERRORS = %w[Dalli::DalliError Redis::BaseError].freeze
36+
3337
class << self
34-
attr_accessor :enabled, :notifier, :throttle_discriminator_normalizer
38+
attr_accessor :enabled,
39+
:notifier,
40+
:throttle_discriminator_normalizer,
41+
:error_handler,
42+
:ignored_errors,
43+
:failure_cooldown
44+
3545
attr_reader :configuration
3646

3747
def instrument(request)
@@ -57,6 +67,39 @@ def reset!
5767
cache.reset!
5868
end
5969

70+
def failed!
71+
@last_failure_at = Time.now
72+
end
73+
74+
def failure_cooldown?
75+
return unless @last_failure_at && failure_cooldown
76+
Time.now < @last_failure_at + failure_cooldown
77+
end
78+
79+
def ignored_error?(error)
80+
ignored_errors&.any? do |ignored_error|
81+
case ignored_error
82+
when String then error.class.ancestors.any? {|a| a.name == ignored_error }
83+
else error.is_a?(ignored_error)
84+
end
85+
end
86+
end
87+
88+
def calling?
89+
!!thread_store[THREAD_CALLING_KEY]
90+
end
91+
92+
def with_calling
93+
thread_store[THREAD_CALLING_KEY] = true
94+
yield
95+
ensure
96+
thread_store[THREAD_CALLING_KEY] = nil
97+
end
98+
99+
def thread_store
100+
defined?(RequestStore) ? RequestStore.store : Thread.current
101+
end
102+
60103
extend Forwardable
61104
def_delegators(
62105
:@configuration,
@@ -84,7 +127,11 @@ def reset!
84127
)
85128
end
86129

87-
# Set defaults
130+
# Set class defaults
131+
self.failure_cooldown = DEFAULT_FAILURE_COOLDOWN
132+
self.ignored_errors = DEFAULT_IGNORED_ERRORS.dup
133+
134+
# Set instance defaults
88135
@enabled = true
89136
@notifier = ActiveSupport::Notifications if defined?(ActiveSupport::Notifications)
90137
@throttle_discriminator_normalizer = lambda do |discriminator|
@@ -100,32 +147,87 @@ def initialize(app)
100147
end
101148

102149
def call(env)
103-
return @app.call(env) if !self.class.enabled || env["rack.attack.called"]
150+
return @app.call(env) if !self.class.enabled || env["rack.attack.called"] || self.class.failure_cooldown?
104151

105-
env["rack.attack.called"] = true
152+
env['rack.attack.called'] = true
106153
env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO'])
107154
request = Rack::Attack::Request.new(env)
155+
result = :allow
108156

157+
self.class.with_calling do
158+
result = get_result(request)
159+
rescue StandardError => error
160+
return do_error_response(error, request, env)
161+
end
162+
163+
do_response(result, request, env)
164+
end
165+
166+
private
167+
168+
def get_result(request)
109169
if configuration.safelisted?(request)
110-
@app.call(env)
170+
:allow
111171
elsif configuration.blocklisted?(request)
112-
# Deprecated: Keeping blocklisted_response for backwards compatibility
113-
if configuration.blocklisted_response
114-
configuration.blocklisted_response.call(env)
115-
else
116-
configuration.blocklisted_responder.call(request)
117-
end
172+
:block
118173
elsif configuration.throttled?(request)
119-
# Deprecated: Keeping throttled_response for backwards compatibility
120-
if configuration.throttled_response
121-
configuration.throttled_response.call(env)
122-
else
123-
configuration.throttled_responder.call(request)
124-
end
174+
:throttle
125175
else
126176
configuration.tracked?(request)
127-
@app.call(env)
177+
:allow
178+
end
179+
end
180+
181+
def do_response(result, request, env)
182+
case result
183+
when :block then do_block_response(request, env)
184+
when :throttle then do_throttle_response(request, env)
185+
else @app.call(env)
186+
end
187+
end
188+
189+
def do_block_response(request, env)
190+
# Deprecated: Keeping blocklisted_response for backwards compatibility
191+
if configuration.blocklisted_response
192+
configuration.blocklisted_response.call(env)
193+
else
194+
configuration.blocklisted_responder.call(request)
195+
end
196+
end
197+
198+
def do_throttle_response(request, env)
199+
# Deprecated: Keeping throttled_response for backwards compatibility
200+
if configuration.throttled_response
201+
configuration.throttled_response.call(env)
202+
else
203+
configuration.throttled_responder.call(request)
204+
end
205+
end
206+
207+
def do_error_response(error, request, env)
208+
self.class.failed!
209+
result = error_result(error, request, env)
210+
result ? do_response(result, request, env) : raise(error)
211+
end
212+
213+
def error_result(error, request, env)
214+
handler = self.class.error_handler
215+
if handler
216+
error_handler_result(handler, error, request, env)
217+
elsif self.class.ignored_error?(error)
218+
:allow
128219
end
129220
end
221+
222+
def error_handler_result(handler, error, request, env)
223+
result = handler
224+
225+
if handler.is_a?(Proc)
226+
args = [error, request, env].first(handler.arity)
227+
result = handler.call(*args) # may raise error
228+
end
229+
230+
%i[block throttle].include?(result) ? result : :allow
231+
end
130232
end
131233
end

lib/rack/attack/store_proxy/dalli_proxy.rb

+8-22
Original file line numberDiff line numberDiff line change
@@ -24,34 +24,26 @@ def initialize(client)
2424
end
2525

2626
def read(key)
27-
rescuing do
28-
with do |client|
29-
client.get(key)
30-
end
27+
with do |client|
28+
client.get(key)
3129
end
3230
end
3331

3432
def write(key, value, options = {})
35-
rescuing do
36-
with do |client|
37-
client.set(key, value, options.fetch(:expires_in, 0), raw: true)
38-
end
33+
with do |client|
34+
client.set(key, value, options.fetch(:expires_in, 0), raw: true)
3935
end
4036
end
4137

4238
def increment(key, amount, options = {})
43-
rescuing do
44-
with do |client|
45-
client.incr(key, amount, options.fetch(:expires_in, 0), amount)
46-
end
39+
with do |client|
40+
client.incr(key, amount, options.fetch(:expires_in, 0), amount)
4741
end
4842
end
4943

5044
def delete(key)
51-
rescuing do
52-
with do |client|
53-
client.delete(key)
54-
end
45+
with do |client|
46+
client.delete(key)
5547
end
5648
end
5749

@@ -66,12 +58,6 @@ def with
6658
end
6759
end
6860
end
69-
70-
def rescuing
71-
yield
72-
rescue Dalli::DalliError
73-
nil
74-
end
7561
end
7662
end
7763
end

0 commit comments

Comments
 (0)