Wednesday, May 25, 2011

Ruby, an Exceptional Language

Based on the book Exceptional Ruby by Avdi Grimm, I have developed a strategy for how I should deal with exceptions in Ruby.

Being a very dynamic language, Ruby allows very flexible coding techniques. Exceptions are not an exception :).

When I am developing a library in Ruby I typically create one Error module and one StdError class. The Error module is a typical tag module and does not contain any methods.

Tag Module

# Tag module for the Tapir library 
module Tapir
  module Error
  end 
end 
 

The reason for the tag module is that I can use it to tag exceptions occurring inside my library without having to wrap them in a nested exception.

module Tapir
  class Downloader
 
    def self.get url
      HTTP.get url
    rescue StandardError -> error   # Rescue the error 
      # Namespace the error by tagging it with ::Tapir::Error 
      error.extend(::Tapir::Error)  
      raise                         # And raise it again 
    end 
 
  end 
end 
 
 
# Client usage 
begin 
  Tapir::Downloader.get 'http://non.existent.url/' 
rescue Tapir::Error => error
  puts "Stupid tapir, gave me error #{error.message}" 
end 
 

This is beautiful. I am scoping an internal error as my own. Since Ruby is dynamic there is no need to declare a new class that wraps all the methods in the StandarError I have access to them anyway. Duck typing for the win!

A Nested Exception Class

In some cases the tag module is not enough. Perhaps the exception was not created by another exception. In that case I need a real class since it is not possible to raise modules. But while I am at it I usually make the class a nested exception in order to simplify wrapping of other exceptions if the need comes up. This is how I do that.

module Tapir
  # I usually call the class `StdError` since it prevents the user of 
  # the library from rescuing the global `StandardError`. 
  class StdError < StandardError 
    extend Error             # Extend the Error tag module 
    attr_reader :original    # Add an accessor for the original, if one exists 
   
    # Create the error with a message and an original that defaults to 
    # the exception that is currently active, in this thread, if one exists 
    def initialize(msg, original=$!) 
      super(msg) 
      @original = original; 
    end 
  end 
end 
 
 
# Client Usage 
begin 
  Tapir.do_something_that_fails
rescue Tapir::Error => error      # rescue the tag module 
  puts "Bad tapir #{error.message}, due to #{error.original.message}" 
end 
 
# or if I want to be more specific 
begin 
  Tapir.do_something_that_fails
rescue Tapir::StdError => error   # rescue the specific error 
  puts "Bad tapir #{error.message}, due to #{error.original.message}" 
end 
 

Notice that I don’t have to wrap the exception explicitly, since I default the Exception to the last error that is stored in $!.

Now the only reason for me to want to create a Tapir::StdError apart from it being misuse of my library is if I want to add additional information to the exception that already occurred. In that case I may also want to extend the Tapir::StdError and create an exception with additional fields.

module Tapir
  
  # Create a specific exception to add more information for the client 
  class TooOldError < StdError
    attr_reader :age, :max_age
 
    def initialize(msg, original=$!, age, max_age) 
      super(msg, original) 
      @age, @max_age = age, max_age
    end 
  end 
end 
 
# Client usage 
begin 
  tapir.mate(other_tapir) 
rescue TooOldError => error
  # Use the specific error properties 
  puts "Hey, your are #{error.age}, that is too damn old!" 
end 
 

Throw – Catch

Ruby also has an alternative to raise and rescue called throw and catch.

They should not be used as an alternative to exceptions, instead they are escape continuations that should be used to escape from nested control structures across method calls. Powerful! Here is an example from Sinatra

# Here is the throw 
 
   # Pass control to the next matching route. 
    # If there are no more matching routes, Sinatra will 
    # return a 404 response. 
    def pass(&block) 
      throw :pass, block
    end 
 
# and here is where it is caught 
 
    def process_route(pattern, keys, conditions) 
      ... 
      catch(:pass) do 
        conditions.each { |cond| 
          throw :pass if instance_eval(&cond) == false } 
        yield 
      end 
    end 
 
 
# Allowing usage such as 
 
  get '/guess/:who' do 
    pass unless params[:who] == 'Frank' 
    'You got me!' 
  end 
 
  get '/guess/*' do 
    'You missed!' 
  end 
 

Lovely!

Wrap up

This is how I use exceptions in Ruby now, thanks to ideas from the book. Other good ideas from the book are the three guarantees:

  • The weak guarantee, if an exception is raised, the object will be in a consistent state.
  • The strong guarantee, if an exception is raised, the object will be left in its initial state.
  • The nothrow guarantee, no exceptions will be raised by this method.

And a nice way of categorizing exceptions based on three different usages by the client. (My categories are not exactly the same as Avdis)

  • User Error, the client has used the library wrong.
  • Internal Error, something is wrong with the library. We are looking into the problem…
  • Transient Error, something is now working right now, but the same call may succeed in a while. It is a good idea to provide a period after whick the call will probably succeed. the client to try again.

It is a great book which contains a lot more information than I covered here. Get it, it is well worth the money.

3 comments:

  1. Thanks for the kind words! Glad you're enjoying the book!

    ReplyDelete
  2. @Avdi, I really like books like your. Focused and with good examples.

    ReplyDelete
  3. Since you can raise anything that responds to #exception maybe you can do something with that, i.e.

    module Tapir
    module Error
    def exception(msg=nil, orig=$!)
    $!.extend self
    end
    end
    end

    Need to flesh out the exact definition but you get my meaning.

    ReplyDelete