How to Retry in Ruby Apps
What is retrying and why is it important?
As developers, you must have observed how several code operations and requests by their very nature are likely to fail a few times arbitrarily. This could be a result of network overloads, bottlenecks, latency, and other common application issues.
So the idea broadly here is to acknowledge such scenarios and have mechanisms in place that can allow us to repeat code operations, for example – when dealing with exception handling blocks, loops, and anywhere else. Repeat stuff by going back in code — that’s the theme around which the introduced Ruby concepts will revolve.
How do I retry when programming in Ruby?
Ruby shines in this regard by providing several control flow keywords to make it easier for developers to have more freedom and flexibility over their program. So in this post, we will look at three super helpful Ruby concepts that allow developers to utilize these mechanisms to write cleaner, more effective code.
We will be discussing redo, retry, and continuations in Ruby. We’ll talk about what they are, how they are used, cover some common use cases, and clear some of the confusions surrounding these keywords.
What is Ruby Retry?
As the name suggests, Ruby Retry allows you to retry running a block of code.
begin
raise # an exception
rescue
retry # ⤴
end
Retry is used primarily in the context of exception handling in Ruby. When your program encounters an exception inside a begin block, control moves to the rescue block where the exception is handled. This is conventionally followed by the execution of the code below the rescue block. So if you think about it, the code in the begin block gets only one chance to do its thing.
Why should I use Ruby Retry?
Some functions deserve a second chance. There can be several use cases for wanting your begin block to make amends. For example, HTTP requests and database calls can sometimes fail or time out at their first go due to an overloaded server taking longer to respond. In such cases, you might want to rerun the block a few more times before you give up.
How do I use Ruby Retry?
This can be easily achieved using the retry statement baked into Ruby’s exception handling system. Let’s see an example –
# retry.rb
begin
attempts ||= 1 # keeping track of retries
puts "doing arbitrary task (attempt ##{ attempts })"
raise "an exception occurred" # manually raising error
rescue => error
puts error.message
if (attempts += 1) < 5 # go back to begin block if condition ok
puts "<retrying..>"
retry # ⤴
end
puts "-------------------------"
puts "Retry attempts exceeded. Moving on."
end
In the code above, we define a begin block where we vaguely simulate a code operation that raises an error. When handling this error in the rescue block, we check for the number of attempts (tracked using the attempts variable) and choose to retry and go back or move ahead. Upon calling retry, all of the code inside the begin block is executed again. Below is what the output looks like —
$ ruby retry.rb
doing arbitrary task (attempt #1)
an exception occurred
<retrying..>
doing arbitrary task (attempt #2)
an exception occurred
<retrying..>
doing arbitrary task (attempt #3)
an exception occurred
<retrying..>
doing arbitrary task (attempt #4)
an exception occurred
-------------------------
Retry attempts exceeded. Moving on.
A few things to mention here –
-
Note the utility of a condition check in the rescue block for choosing whether to retry or not. As you can see, it was necessary for us to maintain a counter-like variable and to check its value to prevent our code from going into an infinite loop (begin -> rescue -> retry -> begin .. and so on).
-
Another seemingly obvious thing to note here is that when calling retry, the code doesn’t go back in time to an earlier checkpoint in the computation graph but instead just re-executes the begin block.
-
Since Ruby 1.9 onwards, retry works only for exception-handling within begin/rescue blocks (otherwise, an “invalid retry” error is raised).
This was just a simple example to demonstrate how retry can be used. As you can imagine, we can similarly extend this for more practical use cases like the network request time-outs we mentioned previously. Let’s look at an example of that.
Example: Ruby Retry
Here, we’ll take a more practical example to see retry in action. We’ll try to make an HTTP request to a (non-routable) URL and test our explicit retry attempts when the request times out.
# retry-2.rb
require "net/http"
http = nil
uri = URI("https://www.google.com:81") # a URL that we know will time our request out
begin
attempts ||= 1
unless http
puts "Opening TCP connection to #{uri.to_s}"
http = Net::HTTP.start(uri.host, uri.port, open_timeout: 10) # error raised here
end
puts "Making HTTP GET request..."
puts http.request_get(uri.path).body
rescue Net::OpenTimeout => e # catching timeout exception
puts "Timeout: #{e} (attempt ##{ attempts })"
if (attempts += 1) <= 5 # retry if condition ok
puts "<retrying..>"
retry # ⤴
else
puts "--------------------------"
puts "Retry attempts exceeded. Moving on."
end
ensure
if http # if the request is successful
puts "Closing the TCP connection..."
http.finish
end
end
Here, after attempting to open the connection in the begin block, we rescue the timeout exception and retry our request four additional times before we finally move ahead. Below is what the output looks like:
$ ruby retry-2.rb
Opening TCP connection t https://www.google.com:81
Timeout: execution expired (attempt #1)
<retrying..>
Opening TCP connection to https://www.google.com:81
Timeout: execution expired (attempt #2)
<retrying..>
Opening TCP connection to https://www.google.com:81
Timeout: execution expired (attempt #3)
<retrying..>
Opening TCP connection to https://www.google.com:81
Timeout: execution expired (attempt #4)
<retrying..>
Opening TCP connection to https://www.google.com:81
Timeout: execution expired (attempt #5)
--------------------------
Retry attempts exceeded. Moving on.
One possible limitation of retry is that it re-runs everything inside your begin block. There could be cases where you might want to retry only the erroneous statement instead of the whole block. Therefore, this is just something developers must keep in mind when writing and structuring their exception handling code (for instance, opting for more targeted begin blocks to overcome the above limitation).
Now let’s look at another keyword in Ruby that operates with similar philosophy but in a different context.
What is Ruby Redo?
Ruby’s redo allows developers to repeat (or redo) loop iterations. You can think of it as a retry for loops instead of for begin/rescue blocks.
while condition1 do
if condition2
redo # the current iteration ⤴
end
end
Even though it’s pretty straightforward, I think it helps to contrast redo with other control flow operators like break and continue break (which terminates and exits the loop altogether), and continue (which terminates the current iteration and moves to the next one). redo, on the other hand, repeats the current iteration.
Why should I use Ruby Redo?
As you can imagine, what Ruby’s redo brings to the table is not something that quick workarounds can’t easily achieve. However, having an operator that can simplify things always helps in writing cleaner and more efficient code.
How do I use Ruby Redo?
Let’s do an example. We’ll use the redo operator to add multiple entities (fruits) from one array to another (cart).
Example: Ruby Redo
# redo.rb
fruits = ["banana", "apple ", "mango", "kiwi"]
cart = []
counter = 0 # for tracking number of redos
for fruit in fruits
puts "Added #{fruit} to cart: #{cart.append(fruit)}"
if fruit == 'apple' # arbitrary condition check
redo if (counter += 1) < 4 # ⤴ redo three more times
end
end
As you can see here, we redo the adding of an apple to our cart three more times based on a counter variable used for tracking the number of redos. Below is what our code’s output looks like–
$ ruby redo.rb
Added banana to cart: ["banana"]
Added apple to cart: ["banana", "apple"]
Added apple to cart: ["banana", "apple", "apple"]
Added apple to cart: ["banana", "apple", "apple", "apple"]
Added apple to cart: ["banana", "apple", "apple", "apple", "apple"]
Added mango to cart: ["banana", "apple", "apple", "apple", "apple", "mango"]
Added kiwi to cart: ["banana", "apple", "apple", "apple", "apple", "mango", "kiwi"]
Since Ruby 1.9, redo only works within loops (like retry only works within exception handling). However, you can perhaps imagine a way to use redo to emulate the functionality of a retry when used in conjunction with begin/rescue blocks.
Example: Redo like Retry
Let’s revisit the second last example where we retried HTTP requests that timed out. Now suppose if we were to make requests to multiple URLs and reattempt these upon timeout. This will allow us to bring a loop in the scene and use our redo operator for retrying. Let’s look at an example:
# redo-2.rb
require "net/http"
http = nil
uris = [URI("https://www.google.com:81/"), URI("https://www.google.com/")] # one url that will timeout and another that won't
for uri in uris
begin
attempts ||= 1
puts "Opening TCP connection to #{uri.to_s}"
http = Net::HTTP.start(uri.host, uri.port, open_timeout: 5)
puts "Connection object: #{http}”
rescue Net::OpenTimeout => e # catching timeout exception
puts "Timeout: #{e} (attempt ##{ attempts })"
if (attempts += 1) < 5 # redo if condition ok
puts "<redoing..>"
redo # ⤴
else
puts "--------------------------"
puts "Redo attempts exceeded. Moving on."
end
ensure
if http # if the request is successful
puts "Closing the TCP connection..."
puts "--------------------------"
http.finish
end
end
end
Here, we try to make HTTP requests to two URLs (one that will timeout, another that won’t) in an array and utilize the redo operator for repeating the requests upon timeout. This is similar to our second last example, except that we are iterating over multiple URLs here through a loop – which allows us to replace retry with redo. Below is the output of the code:
$ ruby redo-2.rb
Opening TCP connection to https://www.google.com:81/
Timeout: execution expired (attempt #1)
<redoing..>
Opening TCP connection to https://www.google.com:81/
Timeout: execution expired (attempt #2)
<redoing..>
Opening TCP connection to https://www.google.com:81/
Timeout: execution expired (attempt #3)
<redoing..>
Opening TCP connection to https://www.google.com:81/
Timeout: execution expired (attempt #4)
--------------------------
Redo attempts exceeded. Moving on.
Opening TCP connection to https://www.google.com/
Connection object: #<Net::HTTP:0x00007fc9c98d5b68>
Closing the TCP connection...
--------------------------
What are Ruby Continuations?
Now let’s look at a mechanism slightly different and unconventional from those that we have been discussing so far – Ruby’s continuation.
As you can see, Ruby retry and Ruby redo allow us to move a program’s control backward – either to the preceding begin block or to the start of a loop’s iteration. However, these statements enforce certain constraints in that the control can flow back only to predetermined points in your code. This means that, conventionally, there isn’t complete freedom for developers in choosing how far back they would like the code’s control to flow. This is where continuations come into the picture.
Why should I use Continuations in Ruby on Rails?
Continuations enable storing a computation state that can be returned to anytime during a program’s execution – from anywhere in your code. It represents a current point of execution in your program. You can think of it as a checkpoint in your code’s execution that you can jump back to from anywhere you like. This is likely to remind you of the goto and setjmp/longjmp statements from the C language.
How do I use Continuations in Ruby on Rails?
Continuations might seem somewhat complicated at first but can be easily understood using a couple of simple examples.
Example: Ruby Continuations
# my_continuation.rb
require "continuation" # explicit import
ctr = 0
my_cont = callcc { |c| c } # define continuation checkpoint ⬅
puts “Counter: #{ctr += 1}” # ⬇
my_cont.call(my_cont) if ctr < 5 # call (go back to) checkpoint ⤴
Here, as you can see, we need to export the continuation library into our code explicitly. This is because the callcc function we need to use has been declared obsolete by Ruby.
The callcc (short for ‘call with current continuation’) is a Kernel method that takes a block and is used to create a continuation object (my_cont). Now, each time we use the continuation to jump back to our previous checkpoint, we pass through the call function the continuation object itself that is then assigned to my_cont.
Using this, we are able to recurse back to our checkpoint until the counter runs out of its limit (like in previous examples). Note the arrows in the code’s comments to understand the control flow of the example.
Below is the output of our code:
$ ruby my_continuation.rb
/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/continuation.bundle: warning: callcc is obsolete; use Fiber instead
Counter: 1
Counter: 2
Counter: 3
Counter: 4
Counter: 5
Let’s take a look at another example. Here, we’ll also try to pass an arbitrary parameter back to our continuation checkpoint.
# my_continuation-2.rb
require "continuation"
ctr = 0
my_cont = nil
puts "The call/cc function returned: #{callcc { |c| my_cont = c;}}" # ⬅
# ⬇
ctr += 1
# ⬇
my_cont.call("hello world (#{ctr})") if ctr < 3 # ⤴
my_cont.call("jello world (#{ctr})") if ctr < 6 # ⤴
if ctr < 9
my_cont.call("mellow world (#{ctr})") + 0 # ⤴
puts "I am not executed!"
end
Here, as you can see, we pass strings back to our checkpoint and play around with different counter values. One interesting thing to note here would be how even the ‘+ 0’ in the third from the last line doesn’t raise a TypeError (“no implicit conversion of Integer into String”). This is because the control is already sent back to our continuation checkpoint before the latter executes. Below is what the output looks like:
$ ruby my_continuation-2.rb
/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/continuation.bundle: warning: callcc is obsolete; use Fiber instead
The call/cc function returned: #<Continuation:0x00007fa981093350>
The call/cc function returned: hello world (1)
The call/cc function returned: hello world (2)
The call/cc function returned: jello world (3)
The call/cc function returned: jello world (4)
The call/cc function returned: jello world (5)
The call/cc function returned: mellow world (6)
The call/cc function returned: mellow world (7)
The call/cc function returned: mellow world (8)
Here, callcc returns the continuation object the first time and the passed strings subsequently.
So, a continuation essentially allows us to continue from an earlier position in our code and can be thought of as just another helpful control statement. However, it is worthwhile to note that due to several criticisms against call/cc cited by experts, you are significantly less likely to find continuations being used in production.
Nevertheless, it’s always helpful to be aware of language features that provide developers with more control over their code, allowing them to better understand the abstractions and the purpose they serve.
Closing Thoughts on Ruby Retry, Ruby Redo, and Continuation
In this post, we looked at three relatively less popular (but super helpful!) mechanisms in Ruby on Rails that grant more freedom to developers in their program’s control flow and the code they can write. We discussed the Ruby retry keyword – which gives our begin block another chance when an exception is caught using rescue. After going through a couple of its examples, we looked at the Ruby redo keyword and how it can be used to repeat loop iterations. In the end, we covered Ruby continuations, how they work, and what they bring to the table.
Now that you have a good understanding of these concepts go ahead and try them out in your code. Think about where these mechanisms could be utilized in your applications, implement them, and notice how they could make things simpler.
To learn more about working with exceptions in Ruby, you can check out the Exception Handling in Ruby post on our blog. And if you are serious about getting the most out of your Ruby applications, getting actionable insights for optimizing performance, live alerts about potential issues, bottlenecks, and lots more, check out ScoutAPM and get started with a 14-day free trial.