Raise and Rescue

by Hugh Bien — 01/14/2016

Exception handling lets developers create a separate flow for special cases. It keeps code clean. For example:

def print_file(filename)
  if File.exist?(filename)
    if File.readable?(filename)
      File.open(filename) { |f| puts(f.read) }
    else
      puts "Can't read file: #{filename}"
    end
  else
    puts "File doesn't exist: #{filename}"
  end
end

The method's original intent is hidden! Let's extract the error handling:

def print_file(filename)
  File.open(filename) { |f| puts(f.read) }
rescue Errno::ENOENT
  puts "File doesn't exist: #{filename}"
rescue Errno::EACCES
  puts "Can't read file: #{filename}"
end

The special cases are moved to the bottom and intent becomes clear. We use the basics of exceptions every day. If we get to know the internals a bit, we might learn new tricks.

Let's deep dive into Ruby's exceptionally useful world of error handling.

How Does Raise Work?

raise is just a method on Kernel, which is a module that gets included by the base class Object. That's why you have access to it everywhere. Since it's just a method, you can override it. Let's look at the three ways to raise:

raise
raise(string)
raise(exception_class_or_obj, optional_message, optional_stack_trace)

Calling without any arguments will default to raising a RuntimeError. If you're in the context of another error it will re-raise:

begin
  method_that_raises(ArgumentError)
rescue ArgumentError => error
  logger.info("Error: #{error}")
  raise # re-raises the argument error
end

Calling with a string will instantiate a new RuntimeError and set the error message. It's useful for some quick debugging.

Raise is much more useful when you provide context, such as an exception class/object and message:

raise ArgumentError.new("must be positive integer but was provided: #{num}")
raise ArgumentError, "must be positive integer but was provided: #{num}"
raise ArgumentError, "must be positive integer but was provided: #{num}", caller(0)

The three lines above are identical. That last argument is a stack trace — Ruby uses an array of strings. caller(0) will provide the current stack trace.

Raise doesn't explicitly instantiate a new exception object. Instead, it calls #exception:

def raise(exception_class_or_obj, message, stack_trace)
  error = exception_class_or_obj.exception(message)
  # ...
end

After being raised, program execution is bubbled up to the matching rescue block.

How Does Rescue Work?

rescue attempts to match with a raised exception. Rescue blocks should be short. Its intent is to handle the exception and bring your code back to a good state. If that's not possible, fail with an explicit message.

Whereas raise defaults to RuntimeError, rescue defaults to StandardError. The two lines below are identical:

rescue
rescue StandardError

Explicitly passing an exception will handle that class and its children. RuntimeError is a child of StandardError, so the default rescue will handle it.

It's usually frowned upon to rescue Exception. Exception is the base class of all exceptions in Ruby, including system errors such as SignalException. Rescuing it means users won't be able to Ctrl-C out of your program.

Internally, Ruby uses the === class method to determine if an exception should be rescued or not. You can actually define your own handler:

module CustomExceptionHandler
  def self.===(exception)
    exception.message =~ /42/
  end
end

begin
  raise RuntimeError, "answer to the universe: 42"
rescue CustomExceptionHandler => error
  # handle error
end

Some Useful Patterns

Guard your method with raises near the top. This provides clear preconditions to callers:

def my_picky_method(left, right)
  raise ArgumentError("#{left} must be less than #{right}") if left >= right
  # ...
end

Keep error handling near the bottom. This lets your method's intent stay clear and prioritizes the normal flow of execution:

def my_clear_method(key)
  process_some_data(key)
rescue KeyError => error
  logger.info("Unexpected key: #{error.message}")
rescue TypeError => error
  logger.info("Unexpected type: #{error.message}")
end

Define your own exceptions. Think about creating a hierarchy of custom exceptions for your application. You'll allow more precision for exception handling and uncaught exceptions will have much better explanations. Also consider providing more context, such as a user id:

class AppBaseError < StandardError; end;
class APIError < AppBaseError; end;
class ClientError < AppBaseError; end;
class ServerError < AppBaseError; end;
class UserNotFoundError < AppBaseError
  attr_reader :user_id
  def initialize(message='User not found', user_id=nil)
    @user_id = user_id
    super(message)
  end
end

Implement #exception for some syntactic sugar. Remember that raise works with any object or class that defines the #exception method:

class APIRequest
  def exception(message)
    error = APIError.new(message)
    error.request = self
    error
  end
end

request = APIRequest.new
request.do_something()
raise request if request.retries > 10

Override raise. Raise is just a method which can be overridden like any other. Think about which contexts this might be useful in:

module RaiseLogger
  def raise(exception_class_or_obj=RuntimeError, message=nil, trace=nil)
    logger.info(exception_class_or_obj)
    super
  end
end

class UsersController
  include RaiseLogger
  # ...
end

Learning the internals of exception handling helps us create useful patterns. It's also fun! But at the end of the day, raise and rescue are just tools. We use them to write cleaner code.

––––

Follow me via RSS or .

You may also enjoy:
A History of Computing· Thyme Pomodoro Timer· SEO Tactics