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