Not all errors are fatal. Some just indicate that you need to try again. Fortunately, Ruby provides a few interesting mechanisms that make it easy to "try again" - though not all of them are obvious or well-known. In this post we'll take a look at these mechanisms and how they work in the real world.

Introducing retry

Ok - this one is kind of obvious, but only if you know it exists. Personally, I was well into my Ruby career before I learned about the delightful "retry" keyword.

Retry is built in to Ruby's exception rescuing system. It's quite simple. If you use "retry" in your rescue block it causes the section of code that was rescued to be run again. Let's look at an example.

begin
  retries ||= 0
  puts "try ##{ retries }"
  raise "the roof"
rescue
  retry if (retries += 1) < 3
end

# ... outputs the following:
# try #0
# try #1
# try #2

There are a few things to note here:

  • When retry is called, all of the code in between begin and rescue is run again. It most definitely does not "pick up where it left off" or anything like that.

  • If you don't provide some mechanism to limit retries, you will wind up with an infinite loop.

  • Code in both the begin and rescue blocks are able to access the same retries variable in the parent scope.

The Problem With retry

While retry is great it does have some limitations. The main one being that the entire begin block is re-run. But sometimes that's not ideal.

For example, imagine that you're using a gem that lets you post status updates to Twitter, Facebook, and lots of other sites by using a single method call. It might look something like this.

SocialMedia.post_to_all("Zomg! I just ate the biggest hamburger")

# ...posts to Twitter API
# ...posts to Facebook API
# ...etc

If one of the APIs fails to respond, the gem raises a SocialMedia::TimeoutError and aborts. If we were to catch this exception and retry, we'd wind up with duplicate posts because the retry would start over from the beginning.

begin
  SocialMedia.post_to_all("Zomg! I just ate the biggest hamburger")
rescue SocialMedia::TimeoutError
  retry
end

# ...posts to Twitter API
# facebook error
# ...posts to Twitter API
# facebook error
# ...posts to Twitter API
# and so on

Wouldn't it be nice if we were able to tell the gem "Just skip facebook, and keep on going down the list of APIs to post to."

Fortunately for us, Ruby allows us to do exactly that.

NOTE: Of course the real solution to this problem is to re-architect the social media library. But this is far from the only use-case for the techniques I'm going to show you.

Continuations to the Rescue

Continuations tend to scare people. But that's just because they're not used very frequently and they look a little odd. But once you understand the basics they're really quite simple.

A continuation is like a  "save point" in your code, just like in a video game. You can go off and do other things, then jump back to the save point and everything will be as you left it.

...ok, so it's not a perfect analogy, but it kind of works. Let's look at some code:

require "continuation"
counter = 0
continuation = callcc { |c| c } # define our savepoint
puts(counter += 1)
continuation.call(continuation) if counter < 5 # jump back to our savepoint

You may have noticed a few weird things. Let's go through them:

  • We use the callcc method to create a Continuation object. There's no clean OO syntax for this.

  • The first time the continuation variable is assigned, it is set to the return value of callcc  's block. That's why the block has to be there.

  • Each time we jump back to the savepoint, the continuation variable is assigned whatever argument we pass the call method. That's why we use the weird looking continuation.call(continuation)   syntax.

Adding Continuations to Exceptions

We're going to use continuations to add an skip method to to all exceptions. The example below shows how it should work. Whenever I rescue an exception I should be able to call skip, which will cause the code that raised the exception to act like it never happened.

begin
  raise "the roof"
  puts "The exception was ignored"
rescue => e
  e.skip
end

# ...outputs "The exception was ignored"

To do this I'm going to have to commit a few sins. Exception is just a class. That means I can monekypatch it to add a skip method.

class Exception
  attr_accessor :continuation
  def skip
    continuation.call
  end
end

Now we need to set the continuation attribute for every exception. It turns out that raise is just a method, which we can override.

BTW, the code below is taken almost verbatim from Advi's excellent slide deck Things You Didn't know about Exceptions.  I just couldn't think of a better way to implement it than this:

require 'continuation'
module StoreContinuationOnRaise
  def raise(*args)
    callcc do |continuation|
      begin
        super
      rescue Exception => e
        e.continuation = continuation
        super(e)
      end
    end
  end
end

class Object
  include StoreContinuationOnRaise
end

Now I can call the skip method for any exception and it will be like the exception never happened.