Creating custom exceptions specific to your Ruby on Rails app’s needs is a powerful way to convey intent. What do they look like and where should you put them?
- Introducing
raise "This is bad"
- The Ruby Exception class
- Where to put your custom exception
- Writing your custom exception
Introducing raise "This is bad"
In one of the projects I was brought into, I saw a lot of raise "Error"
. By a lot, I mean more than 100+. This would
result in control flows that looked like this:
begin
do_something_bad
rescue => e
if e.message == "this"
handle_this_in_this_way(e)
else
handle_this(e)
end
end
Almost as bad:
value = do_something rescue nil
Scary, right?
Our error reporter will treat all of these the same. This turns out to be a disaster when you have more than a few developers working on exceptions.
But this isn’t about writing predictable code. This is about using custom exceptions to convey intent.
The Ruby Exception class
Descendants of Exception
are used to communicate between Kernel#raise
and rescue
statements in begin ... end
blocks.
Exception
objects hold information about the exception — like its type, and optionally a description or trace.
Applications should subclass StandardError
or RuntimeError
to provide custom classes and add additional information.
When an exception has been raised but not yet handled from rescue
, ensure
, at_exit
or END
blocks, the global $!
will contain the current exception and $@
will contain the current exception’s backtrace.
It’s recommended that an application should have a single subclass of StandardError
or RuntimeError
and have specific
exception types inherit from it. This allows the user to rescue a generic exception to catch all exceptions the application may raise,
even if future versions of the application add new exception subclasses.
class MyApp
class Error < StandardError; end
class WidgetError < Error; end
class ThingError < Error; end
end
To handle both WidgetError
and ThingError
, the user can rescue MyApp::Error
.
Where to put your custom exception
Before we write a custom exception, we need to understand how Ruby on Rails is going to find it. Because Ruby on Rails has autoload magic, we can’t just assume that the error can come from anywhere.
This leaves us with a few options:
1. Use require
in our Ruby on Rails application to load them
2. Use Ruby on Rails conventions to place errors in a file it can find
3. Write the errors in the class
Option 1
means we start to work against Ruby on Rails conventions, which is bad.
Option 2
is nice, but it can get quickly out of hand if you decide to have lots of different types of errors.
Option 3
is practical, doesn’t introduce a lot of cognitive load, and can fit nicely in all sorts of code.
Combining 2
and 3
might be the most ideal solution: this way we can have DRY exceptions if we need them, but specific
errors as we require them.
lib/my_app.rb
is what Discourse
uses. Solidus
follows a similar pattern.
module MyApp
class MyAppError < StandardError
def initialize(msg = nil, object: nil, options: {})
super(msg)
@object = object
@options = options
end
end
class GatewayError < MyAppError; end
end
Writing your custom exception
Now that we’ve defined our generic error object MyAppError
, we can start using it in our classes.
class UploadHandler
MAX_FILE_SIZE = 100.megabytes
class UploadSizeError < MyAppError
def message
"File is too big. File should be 1mb, file is #{@obj.file_size}."
end
end
def initialize(upload)
@upload = upload
end
def process
validate_size!
# do something
end
private
def validate_size!
unless @upload.file.size <= MAX_FILE_SIZE
raise UploadSizeError.new('', object: @upload)
end
end
end
That way, in our controller:
def create
@upload = UploadHandler.new(upload_params)
begin
@upload.process
rescue MyAppError => e
flash[:alert] = e.message
return render :new
end
redirect_to uploads_path, notice: "Success!"
end