Last active
June 10, 2020 08:51
-
-
Save henrik/461bc7aa70e00a15b9439812b5aee89f to your computer and use it in GitHub Desktop.
Ruby/Redis code to ignore exceptions unless they've consistently happened for the past X amount of time. Useful e.g. in recurring background workers: https://twitter.com/henrik/status/1149595443992485896 And outside workers, too: https://twitter.com/henrik/status/1270638270901366785 Goes well with https://github.com/barsoom/net_http_timeout_errors.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class IgnoreExceptionsForAWhile | |
REDIS_KEY = "ignore_exceptions_for_a_while" | |
IGNORED_EXCEPTION_RETURN_VALUE = :_ignored_exception | |
# This exception should be ignored in our exception logger (e.g. Honeybadger) config, so devs won't be notified about it. | |
# | |
# This can be used to let a UI show some user-facing error without notifying devs until after the `raise_after` timeout. | |
# Either by just relying on e.g. Ajax request error callbacks to show a helpful error message, or by rescuing this error and showing a message. | |
# | |
# You can call `#cause` on this exception to get the original exception. | |
class IgnoredOriginalException < StandardError; end | |
# Pass `raise_own_error_on_ignore: true` to raise `IgnoredOriginalException` when ignoring. See its docs. | |
def self.call(exception_or_exceptions, name:, raise_after:, raise_own_error_on_ignore: false, &block) | |
exceptions = Array(exception_or_exceptions) | |
key = "#{REDIS_KEY}:#{name}" | |
return_value = block.call | |
# If the block did not fail, reset this. | |
$redis.del(key) unless raise_after.zero? | |
return_value | |
rescue *exceptions | |
raise if raise_after.zero? | |
errors_since_timestamp = $redis.get(key).to_i.nonzero? | |
errors_since = errors_since_timestamp && Time.at(errors_since_timestamp) | |
# First error. Store time and fail silently (or with our own error). | |
unless errors_since | |
$redis.set(key, Time.now.to_i) | |
raise(IgnoredOriginalException) if raise_own_error_on_ignore | |
return IGNORED_EXCEPTION_RETURN_VALUE | |
end | |
if errors_since < raise_after.ago | |
raise | |
else | |
# Fail silently, or by raising our own error. | |
raise_own_error_on_ignore ? raise(IgnoredOriginalException) : IGNORED_EXCEPTION_RETURN_VALUE | |
end | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require "rails_helper" | |
RSpec.describe IgnoreExceptionsForAWhile do | |
it "ignores the specified error or errors for the specified period" do | |
error_prone_code = -> { | |
IgnoreExceptionsForAWhile.call(RangeError, name: "foo", raise_after: 10.minutes) do | |
raise RangeError | |
end | |
} | |
expect { | |
# Returns a specific value when ignoring. | |
value = error_prone_code.call | |
expect(value).to eq(:_ignored_exception) | |
}.not_to raise_error | |
Timecop.travel 9.minutes.from_now | |
expect { | |
value = error_prone_code.call | |
expect(value).to eq(:_ignored_exception) | |
}.not_to raise_error | |
# Now it's been more than 10 minutes! | |
Timecop.travel 1.minute.from_now | |
expect { error_prone_code.call }.to raise_error(RangeError) | |
end | |
it "accepts an array of errors" do | |
error_prone_code = -> { | |
IgnoreExceptionsForAWhile.call([ RangeError ], name: "foo", raise_after: 10.minutes) do | |
raise RangeError | |
end | |
} | |
expect { error_prone_code.call }.not_to raise_error | |
# Now it's been more than 10 minutes! | |
Timecop.travel 10.minutes.from_now | |
expect { error_prone_code.call }.to raise_error(RangeError) | |
end | |
it "raises immediately if 'raise_after' is set to 0" do | |
error_prone_code = -> { | |
IgnoreExceptionsForAWhile.call([ RangeError ], name: "foo", raise_after: 0.seconds) do | |
raise RangeError | |
end | |
} | |
expect { error_prone_code.call }.to raise_error(RangeError) | |
end | |
it "keeps hiding errors indefinitely as long as we succeed sometimes" do | |
error_prone_code = -> { | |
IgnoreExceptionsForAWhile.call(RangeError, name: "foo", raise_after: 10.minutes) do | |
raise RangeError | |
end | |
} | |
successful_code = -> { | |
IgnoreExceptionsForAWhile.call(RangeError, name: "foo", raise_after: 10.minutes) do | |
# Don't raise. | |
:my_success | |
end | |
} | |
expect { error_prone_code.call }.not_to raise_error | |
Timecop.travel 9.minutes.from_now | |
expect { | |
# Returns the block return value. | |
value = successful_code.call | |
expect(value).to eq(:my_success) | |
}.not_to raise_error | |
Timecop.travel 9.minutes.from_now | |
# Now it's been more than 10 minutes since the first error, but since we succeeded since, the clock was reset. | |
expect { error_prone_code.call }.not_to raise_error | |
# Once 10 minutes passes since the last error, we do raise. | |
Timecop.travel 10.minutes.from_now | |
expect { error_prone_code.call }.to raise_error(RangeError) | |
end | |
it "does not hide other errors" do | |
error_prone_code = -> { | |
IgnoreExceptionsForAWhile.call(RangeError, name: "foo", raise_after: 10.minutes) do | |
raise RegexpError | |
end | |
} | |
expect { error_prone_code.call }.to raise_error(RegexpError) | |
end | |
it "raises its own error when ignoring, if given 'raise_own_error_on_ignore: true'" do | |
error_prone_code = -> { | |
IgnoreExceptionsForAWhile.call(RangeError, name: "foo", raise_after: 10.minutes, raise_own_error_on_ignore: true) do | |
raise RangeError | |
end | |
} | |
expect { error_prone_code.call }.to raise_error(IgnoreExceptionsForAWhile::IgnoredOriginalException) | |
Timecop.travel 9.minutes.from_now | |
expect { error_prone_code.call }.to raise_error(IgnoreExceptionsForAWhile::IgnoredOriginalException) | |
# Now it's been more than 10 minutes! | |
Timecop.travel 1.minute.from_now | |
expect { error_prone_code.call }.to raise_error(RangeError) | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment