Cleanly Retrying Blocks of Code After an Exception in Ruby

Friday, 25 May 2012

Recently, I had to write some Ruby code that would publish data to an API endpoint. Sometimes this endpoint would fail due to bad network conditions so I wanted to retry the request a few times before marking the attempt as failed.

Ruby has a very nice syntax for doing this sort of thing, but it wasn't obvious to me at first. Let's walk through a contrived example and attempt to refactor this method so that it automatically retries on an API failure:

def publish_to_api(data={}) 
  begin
    DataLibrary.publish(data)
    logger.info "success!"
  rescue DataLibraryFailureException => e
    logger.info "Oh Noes!"
  end
end

Without knowing any better, the naive ruby developer (me, in this case) might add the ability to retry a failed request like so:

def publish_to_api(data={})
  # retry up to 3 times
  3.times do
    begin
      DataLibrary.publish(data)
      logger.info "success!"
      break # it worked, break out of the loop
    rescue DataLibraryFailureException => e
      logger.info "Oh Noes!"
    end
  end
end

Hmm… this is starting to look pretty ugly right? Let's try to clean it up a bit by using some of the nicities of the ruby language.

The first thing we can do is use the retry keyword:

def publish_to_api(data={})
  tries = 3
  begin
    DataLibrary.publish(data)
    logger.info "success!"
  rescue DataLibraryFailureException => e
    tries -= 1
    if tries > 0
      retry
    else
      logger.info "Oh Noes!"
    end
  end
end

We removed the awkward enclosing loop and replaced it with retry. It reads a little better, but it's actually more code than before. Another nice feature of ruby is that def can be used in place of begin for a rescue block so we can actually clean it up a bit more by removing the explicit begin call, like so:

def publish_to_api(data={})
  tries ||= 3
  DataLibrary.publish(data)
  logger.info "success!"
rescue DataLibraryFailureException => e
  tries -= 1
  if tries > 0
    retry
  else
    logger.info "Oh Noes!"
  end
end

Much better. One thing to note here is that we changed tries = 3 to tries ||= 3 so that tries will only be set to 3 if it hasn't already been set. Otherwise we'd have an infinite loop when the block is retried and tries is set to zero again.

We can still clean it up a bit more:

def publish_to_api(data={})
  tries ||= 3
  DataLibrary.publish(data)
rescue DataLibraryFailureException => e
  if (tries -= 1) > 0
    retry
  else
    logger.info "Oh Noes!"
  end
else
  logger.info "success!"
end

Here we've moved the implicit success call (logger.info "success") to an explicit else clause. This else clause is only triggered if a rescue doesn't happen. It can be thought of as "rescue, otherwise do this thing." Also, we've dropped tries -= 1 directly into the if statement.

This is looking much better than our original attempt at adding retry on failure. It's clean, concise and easy to read.

If you only care about the success case, you could shorten it even further:

def publish_to_api(data={})
  tries ||= 3
  DataLibrary.publish(data)
rescue DataLibraryFailureException => e
  retry unless (tries -= 1).zero?
else
  logger.info "success!"
end

There are a number of gems (retriable for example) that attempt to abstract this ruby idiom away into a cleaner DSL but they all seem a little heavy-weight to me. I find the standard ruby way of doing this very flexible and succinct. You can learn more about the begin, rescue, and end syntax at the Pragmatic Programmer's Guide.