January 11, 2019; in Ruby on Rails

Custom exception patterns in Ruby on Rails

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"

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