Retry
Video transcript & code
In the idealized world of pure programs, operations either work the first time or they don't work at all. Sadly, as soon as we start connecting our software to external services this beautiful dream starts to shatter. Servers go down, networks get overloaded. And sometimes we wind up spending as much time writing code to handle an occasional 502 "Bad Gateway" error as we do on the rest of the code combined.
Let's consider some code that wraps a fictional web service. We've used webmock
to simulate an unreliable connection that causes exceptions to be raised the first two times we try to make a request.
require "net/http"
require "webmock"
include WebMock::API
stub_request(:get, "www.example.org")
.to_raise("Packets devoured by rodents")
.to_raise("Request saw its shadow")
.to_return(body: "OK")
As a result, when we call our wrapper method, it fails with an exception.
require "./setup"
def make_request
result = Net::HTTP.get(URI("http://www.example.org"))
puts "Success: #{result}"
end
make_request
# ~> StandardError
# ~> Packets devoured by rodents
# ~>
# ~> /home/avdi/.gem/ruby/2.1.2/gems/webmock-1.13.0/lib/webmock/response.rb:68:in `raise_error_if_any'
# ~> /home/avdi/.gem/ruby/2.1.2/gems/webmock-1.13.0/lib/webmock/http_lib_adapters/net_http.rb:173:in `build_net_http_response'
# ~> /home/avdi/.gem/ruby/2.1.2/gems/webmock-1.13.0/lib/webmock/http_lib_adapters/net_http.rb:83:in `request'
# ~> /home/avdi/.rubies/ruby-2.1.2/lib/ruby/2.1.0/net/http.rb:1280:in `request_get'
# ~> /home/avdi/.rubies/ruby-2.1.2/lib/ruby/2.1.0/net/http.rb:480:in `block in get_response'
# ~> /home/avdi/.gem/ruby/2.1.2/gems/webmock-1.13.0/lib/webmock/http_lib_adapters/net_http.rb:123:in `start_without_connect'
# ~> /home/avdi/.gem/ruby/2.1.2/gems/webmock-1.13.0/lib/webmock/http_lib_adapters/net_http.rb:150:in `start'
# ~> /home/avdi/.rubies/ruby-2.1.2/lib/ruby/2.1.0/net/http.rb:583:in `start'
# ~> /home/avdi/.rubies/ruby-2.1.2/lib/ruby/2.1.0/net/http.rb:478:in `get_response'
# ~> /home/avdi/Dropbox/rubytapas/257-retry/make_request.rb:12:in `make_request'
# ~> xmptmp-in7623Pte.rb:3:in `<main>'
Knowing that we are dealing with a flaky service, we'd like to update the #make_request
method to try again a few times before giving up. In most programming languages this would involve writing a loop. However, in Ruby we have another option.
In the parameters to the #make_request
method, we initialize a count of tries remaining. Then, we add a rescue
clause to the method. inside the clause, we first log the error. Then we decrement the tries counter. Then we check to see if there are any tries remaining.
If there are, we invoke the retry keyword. If, however, there are no tries left, we re-raise the current exception.
Let's see what happens when we call this method. This time, we can see that the request failed twice, and succeeded the third time.
require "./setup"
def make_request(tries: 3)
result = Net::HTTP.get(URI("http://www.example.org"))
puts "Success: #{result}"
rescue => e
tries -= 1
puts "Error: #{e}. #{tries} tries left."
if tries > 0
retry
else
raise e
end
end
make_request
# >> Error: Packets devoured by rodents. 2 tries left.
# >> Error: Request saw its shadow. 1 tries left.
# >> Success: OK
So what happened here? By invoking retry
, we triggered a feature that is not found in many other languages. retry
tells the Ruby VM to back execution up to the beginning of the nearest begin/rescue/end
block and try again. Since we used the method-level rescue clause, the effective location of the nearest begin
block is the beginning of the current method's code.
There are a couple of points worth noting about this code. First off, it is very important when dealing with retry
to remember to create and use a counter of some kind. Likewise, it is vital to remember to decrement the counter before retrying, and to check the state of the counter before retrying. Miss any of these points, and we end up with an infinite loop.
Secondly, note how we made the counter a method parameter instead of instantiating it inside the method body. This isn't just to make it easy to override. Remember what we said earlier: retry
starts again from the top of the nearest begin
block or, in this case, the current method body. If we had instead made the counter a local variable inside the method body, it would be reinitialized to its original value with every retry. Again, we'd get an infinite loop.
def make_request
tries = 3 # don't do this
result = Net::HTTP.get(URI("http://www.example.org"))
puts "Success: #{result}"
rescue => e
tries -= 1
puts "Error: #{e}. #{tries} tries left."
if tries > 0
retry
else
raise
end
end
So long as we are careful with our tries
counter though, retry
gives us an elegant and concise way to re-attempt failed operations.
We've got a little more time, so let's pare this code down to the bare essentials before we wrap up. I wrote out an if/else statement for maximum clarity, but we can write this more concisely. Since, by definition, triggering the retry means that nothing after it will be executed, we can reduce the if statement to a statement modifier. We also don't technically need to supply an argument to raise
, since it will implicitly re-raise the current error if nothing else is given.
require "./setup"
def make_request(tries: 3)
result = Net::HTTP.get(URI("http://www.example.org"))
puts "Success: #{result}"
rescue => e
tries -= 1
puts "Error: #{e}. #{tries} tries left."
retry if tries > 0
raise
end
And that's enough for today. Happy hacking!
Responses