The following post is mostly code, but hopefully is still easy to follow. The inspiration for this little utility is is that 3rd party API’s can have intermittent network issues and in production you’d be better off re-trying your code once or twice before completely failing and having your users see a 503 or not receiving a notification or something similarly infuriating. Whenever you are making a call over the nextwork, it’s probably good to wrap your code in something like the following. Tests are provided to help the user understand how to use the utility function.

require 'minitest/autorun'

module Utilities
require 'timeout'
# tries is the number of tries to execute the code
# on is an array of the errors
# timeout give an upper limit to the amount of time this method will run
# and delay specifies the number of seconds to sleep between each retry
def self.with_retry(retries: 2, on: [StandardError], timeout: 30, delay: 1, rescued_callback: nil)

# http://ruby-doc.org/stdlib-2.1.1/libdoc/timeout/rdoc/Timeout.html
Timeout::timeout(timeout) do
begin
return yield
rescue *on => e
sleep(delay)
# callback code in case you want to do something special on
# failure like write to a log or what have you.
if rescued_callback
rar = rescued_callback.arity
if rar == 0
rescued_callback.call
elsif rar == 1
rescued_callback.call(e)
end
end
# retry if we have retries left
retry unless (retries -= 1).zero?
# re-raise the error if we don't
raise e
end
end

end

end

# custom errors for test purposes
class CustomError < StandardError; end
class CustomError2 < StandardError; end

class TestUtilities < MiniTest::Test

def test_timeout_after_5_seconds

start_time = Time.now.to_i
assert_raises Timeout::Error do
::Utilities.with_retry(retries: 20, timeout: 5 ) do
raise "an error"
end
end
end_time = Time.now.to_i

assert_equal 5, end_time-start_time

end

def test_delay_works

called = 0
assert_raises Timeout::Error do
::Utilities.with_retry(retries: 20, timeout: 4, delay: 2 ) do
called += 1
raise "an error"
end
end
assert called <= 3

end

def test_tries_works

called = 0
assert_raises RuntimeError do
::Utilities.with_retry(retries: 20, delay: 0 ) do
called += 1
raise "an error"
end
end
assert_equal 20, called

end

def test_wont_catch_just_any_exception

called = 0
assert_raises CustomError do
::Utilities.with_retry(retries: 3, delay: 0, on: CustomError2 ) do
called += 1
raise CustomError.new("my custom error")
end
end

assert_equal 1, called

end

def test_catches_specified_exceptions

called = 0

assert_raises CustomError do
::Utilities.with_retry(retries: 3, delay: 0, on: [CustomError, CustomError2] ) do
called += 1
raise CustomError.new("my custom error")
end
end

assert_equal 3, called

end

def test_rescued_callback_works

called = 0
myerr = nil
callback = ->(err){ called += 1; myerr = err }

assert_raises RuntimeError do
::Utilities.with_retry(retries: 3, timeout: 4, delay: 0, rescued_callback: callback ) do
raise "test errr"
end
end
assert_equal 3, called
assert_equal "test errr", myerr.to_s
end

end

One word of caution. When using code like this you have to be careful about putting too much code inside of your block, the reason is that you will have tendency to partially mutate the state of your data structures. For this reason it may be benificial to operate on copies of objects and only commit the changes if no errors are raised.

If you find this function at all useful please comment below…