In Progress
Unit 1, Lesson 1
In Progress

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