DEV Community

Cover image for Ensuring execution, retrying failures and reraising exceptions in Ruby
Jeff Kreeftmeijer for AppSignal

Posted on • Edited on • Originally published at blog.appsignal.com

Ensuring execution, retrying failures and reraising exceptions in Ruby

Raised exceptions can be rescued to execute an alternative code path when things go wrong, but there are more ways to handle exceptions. In this edition of AppSignal Academy, we'll go over the retry and ensure keywords, and we'll look at reraising rescued exceptions.

Let's pretend we're communicating with an unreliable web API. Aside from it being down every once in a while, it's so slow that requests to it can take seconds. Our library depends on this API, and we need to make it as resilient as possible.

ensure

The ensure keyword is used for ensuring a block of code runs, even when an exception happens.

In our library, we'd like to ensure the TCP connection opened by Net::HTTP.start is closed, even if the request fails because it times out, for example. To do this, we'll first wrap our request in a begin/ensure/end block. The code in the ensure part will always run, even if an exception is raised in the preceding begin block.

In the ensure block, we'll make sure to close the TCP connection by calling Net::HTTP#finish unless the http variable is nil, which can happen opening the TCP connection fails (which will also raise an exception).

require "net/http"

begin
  puts "Opening TCP connection..."
  http = Net::HTTP.start(uri.host, uri.port)
  puts "Sending HTTP request..."
  puts http.request_get(uri.path).body
ensure
  if http
    puts "Closing the TCP connection..."
    http.finish
  end
end
Enter fullscreen mode Exit fullscreen mode

Note: We close the TCP connection manually to allow us to use the connection when retrying later. However, since Net::HTTP.start takes a block which handles ensuring the connection is closed, the sample above can be rewritten to remove the ensure. Interestingly enough, the ensure block is also how this is implemented in Net::HTTP itself.

retry

The retry keyword allows retrying a piece of code in a block. Combined with a rescue block, we can use it to try again if we fail to open the connection, or if the API takes too long to respond.

To do that, we'll add a read_timeout to the Net::HTTP.start call which sets the timeout to 10 seconds. If a response to our request hasn't come in by then, it'll raise a Net::ReadTimeout.

We'll also match on Errno::ECONNREFUSED to handle the API being down completely, which would prevent us from opening the TCP connection. In that case, the http variable is nil.

The exception is rescued and retry is called to start the begin block again, which results in the code doing the same request until no timeout occurs. We'll reuse the http object which holds the connection if it already exists.

require "net/http"

http = nil
uri = URI("http://localhost:4567/")

begin
  unless http
    puts "Opening TCP connection..."
    http = Net::HTTP.start(uri.host, uri.port, read_timeout: 10)
  end
  puts "Executing HTTP request..."
  puts http.request_get(uri.path).body
rescue Errno::ECONNREFUSED, Net::ReadTimeout => e
  puts "Timeout (#{e}), retrying in 1 second..."
  sleep(1)
  retry
ensure
  if http
    puts "Closing the TCP connection..."
    http.finish
  end
end
Enter fullscreen mode Exit fullscreen mode

Now, our request will retry every second until no Net::ReadTimeout is raised.

$ ruby retry.rb
Opening TCP connection...
Executing HTTP request...
Timeout (Net::ReadTimeout), retrying in 1 second...
Executing HTTP request...
Timeout (Net::ReadTimeout), retrying in 1 second...
Executing HTTP request...
Timeout (Net::ReadTimeout), retrying in 1 second...
Executing HTTP request...
... (in an endless loop)
Enter fullscreen mode Exit fullscreen mode

While that might make sure no exception is raised for any timeout ever, retry-hammering it like this certainly won't help to get that API back up again. This is problematic because this code will keep looping forever if the API remains unresponsive. Instead, we should spread our retries and give up after a while.

Giving up: reraising exceptions using raise

When an exception is rescued, the raised exception object is passed to the rescue block. We can use that to extract data from the exception, like printing the message to the log, but we can also use it to reraise the exact same exception, with the same stack trace.

begin
  raise "Exception!"
rescue RuntimeError => e
  puts "Exception happened: #{e}"
  raise e
end
Enter fullscreen mode Exit fullscreen mode

Since we have access to the exception object in the rescue block, we can log the error to the console or an error monitor. In fact, rescuing and reraising is exactly how AppSignal's integrations track errors.

Note: Ruby stores the last raised exception in a variable named $!, and the raise keyword will use it by default. Calling raise without any arguments will reraise the last exception.

In our library, we can use reraising to take the pressure of the API after a couple of retries. To do that, we'll keep track of the number of retries we've done in the retries variable.

Whenever a timeout happens, we'll increment the number and check if it's less than or equal to three, because we'd like to retry three retries at most. If so, we'll retry. If not, we'll raise to reraise the last exception.

require "net/http"

http = nil
uri = URI("http://localhost:4567/")
retries = 0

begin
  unless http
    puts "Opening TCP connection..."
    http = Net::HTTP.start(uri.host, uri.port, read_timeout: 1)
  end
  puts "Executing HTTP request..."
  puts http.request_get(uri.path).body
rescue Errno::ECONNREFUSED, Net::ReadTimeout => e
  if (retries += 1) <= 3
    puts "Timeout (#{e}), retrying in #{retries} second(s)..."
    sleep(retries)
    retry
  else
    raise
  end
ensure
  if http
    puts 'Closing the TCP connection...'
    http.finish
  end
end
Enter fullscreen mode Exit fullscreen mode

By using the retries variable in the call to sleep, we can increase the wait time for every new attempt.

$ ruby reraise.rb
Opening TCP connection...
Executing HTTP request...
Timeout (Net::ReadTimeout), retrying in 1 second(s)...
Executing HTTP request...
Timeout (Net::ReadTimeout), retrying in 2 second(s)...
Executing HTTP request...
Timeout (Net::ReadTimeout), retrying in 3 second(s)...
Executing HTTP request...
Closing the TCP connection...
/lib/ruby/2.4.0/net/protocol.rb:176:in `rbuf_fill': Net::ReadTimeout (Net::ReadTimeout)
...
from reraise.rb:13:in `<main>'
Enter fullscreen mode Exit fullscreen mode

Our request is retried three times before the code gives up and reraises the last error. We can then handle the error one level up, or crash our app if it can't finish its job without the API's response.

A resilient web API client

By combining these methods, we've built a resilient web API client in about twenty lines of code. It'll retry requests if it's down or unresponsive, and we'll give up when it doesn't come back up again.

We hope you learned something new about handling exceptions and would love to know what you thought of this article (or any of the other ones in the AppSignal Academy series). Please don't hesitate to let us know what you think, or if you have any Ruby subjects you'd like to learn more about.

Top comments (0)