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.
Thanks for the kind words! Glad you're enjoying the book!
ReplyDelete@Avdi, I really like books like your. Focused and with good examples.
ReplyDeleteSince you can raise anything that responds to #exception maybe you can do something with that, i.e.
ReplyDeletemodule 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.