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 Newsletter, RSS feed, or Twitter.
You may also enjoy:
Setting Up Nginx with Ansible
·
A History of Computing
·
Thyme Pomodoro Timer
·
All Articles →