academy

Ensuring execution, retrying failures and reraising exceptions in Ruby

Jeff Kreeftmeijer

Jeff Kreeftmeijer on

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).

1require "net/http"
2
3begin
4  puts "Opening TCP connection..."
5  http = Net::HTTP.start(uri.host, uri.port)
6  puts "Sending HTTP request..."
7  puts http.request_get(uri.path).body
8ensure
9  if http
10    puts "Closing the TCP connection..."
11    http.finish
12  end
13end

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.

1require "net/http"
2
3http = nil
4uri = URI("http://localhost:4567/")
5
6begin
7  unless http
8    puts "Opening TCP connection..."
9    http = Net::HTTP.start(uri.host, uri.port, read_timeout: 10)
10  end
11  puts "Executing HTTP request..."
12  puts http.request_get(uri.path).body
13rescue Errno::ECONNREFUSED, Net::ReadTimeout => e
14  puts "Timeout (#{e}), retrying in 1 second..."
15  sleep(1)
16  retry
17ensure
18  if http
19    puts "Closing the TCP connection..."
20    http.finish
21  end
22end

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

1$ ruby retry.rb
2Opening TCP connection...
3Executing HTTP request...
4Timeout (Net::ReadTimeout), retrying in 1 second...
5Executing HTTP request...
6Timeout (Net::ReadTimeout), retrying in 1 second...
7Executing HTTP request...
8Timeout (Net::ReadTimeout), retrying in 1 second...
9Executing HTTP request...
10... (in an endless loop)

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.

1begin
2  raise "Exception!"
3rescue RuntimeError => e
4  puts "Exception happened: #{e}"
5  raise e
6end

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.

1require "net/http"
2
3http = nil
4uri = URI("http://localhost:4567/")
5retries = 0
6
7begin
8  unless http
9    puts "Opening TCP connection..."
10    http = Net::HTTP.start(uri.host, uri.port, read_timeout: 1)
11  end
12  puts "Executing HTTP request..."
13  puts http.request_get(uri.path).body
14rescue Errno::ECONNREFUSED, Net::ReadTimeout => e
15  if (retries += 1) <= 3
16    puts "Timeout (#{e}), retrying in #{retries} second(s)..."
17    sleep(retries)
18    retry
19  else
20    raise
21  end
22ensure
23  if http
24    puts 'Closing the TCP connection...'
25    http.finish
26  end
27end

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

1$ ruby reraise.rb
2Opening TCP connection...
3Executing HTTP request...
4Timeout (Net::ReadTimeout), retrying in 1 second(s)...
5Executing HTTP request...
6Timeout (Net::ReadTimeout), retrying in 2 second(s)...
7Executing HTTP request...
8Timeout (Net::ReadTimeout), retrying in 3 second(s)...
9Executing HTTP request...
10Closing the TCP connection...
11/lib/ruby/2.4.0/net/protocol.rb:176:in `rbuf_fill': Net::ReadTimeout (Net::ReadTimeout)
12...
13from reraise.rb:13:in `<main>'

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.

Share this article

RSS

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps