Skip to content

Instantly share code, notes, and snippets.

@henrik
Last active June 10, 2020 08:51
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save henrik/461bc7aa70e00a15b9439812b5aee89f to your computer and use it in GitHub Desktop.
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.
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
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