The art of errors

It looks like about errors handling in Ruby, everything was already said. Nobody needs another article about the error inheritance structure, yet it is still good to know how it looks. However, errors in Ruby still have its awesomeness that needs to show. Let's put a light on some less known features related to errors.

Retrying errors in a fashionable way

Some processes are very error-prone. A good example is requesting the data from the API or any other external source. A timeout error might be raised, but it does not mean that we can't pull the resource that we are requesting. Sometimes all we need is to give a second chance or even a third. We can achieve it with the retry keyword:

require 'net/http'

retry_count = 0
uri = URI("http://somewebsite.com")

begin
  response = Net::HTTP.get_response(uri)
  # do something with response
rescue Net::ReadTimeout => e
  retry_count += 1
  retry if retry_count < 3

  raise(e)
end

It will request the response three times if Net::ReadTimeout error will be raised. How about making the code more reusable? Let's create a wrapper for retrying specified errors, specified number of times:

module Retryable
  def try_with_retry(error:, retries:, &block)
    retry_count = 0

    begin
      block.call
    rescue error => e
      retry_count += 1
      retry if retry_count < retries

      raise(e)
    end
  end
end

After including the Retryable module, we can attempt to perform any action and retry given number of times when given error will be raised:

try_with_retry(error: Net::ReadTimeout, retries: 3) do
  response = Net::HTTP.get_response(uri)
  # do something with response
end

You can also modify the try_with_retry method to accept multiple errors, which will make the solution even more flexible.

Good practices

While raising and rescuing errors may seem to be a straightforward thing to do, as in other code aspects, some good practices are worth using, so our code is readable and easily extendable.

Raise or fail?

Since raise and fail does the same thing, they shouldn't be used alternately. There is one golden rule to distinct which one should be used:

  • Use raise if you are catching an error thrown by an external gem or library to re-raise the error
  • Use fail if you want to raise the error in your code and let the program know that you failed to do something

Build your error hierarchy

As you may know, in the case of errors' classes, there is a hierarchy. The top class is Exception from which other classes like ScriptError or StandardError inherits. In turn, the StandardError is the parent class for ArgumentError, NameError, etc.

When building the validation process, you may end up with the following code that tries to handle all errors and report to the user:

begin
  do_something
rescue RequiredInputError, UniqInputError => e
  # respond to the user
end

With own error hierarchy the process of rescuing errors is more natural and readable:

class ValidationError < StandardError; end
class RequiredInputError < ValidationError; end
class UniqInputError < ValidationError; end

begin
  do_something
rescue ValidationError => e
  # respond to the user
end

With the above approach, you still have access to e.class, which will return either RequiredInputError or UniqInputError.

Clean up your Rails controllers

If in your Rails controller you rescue from the same error in many actions, you may end up with the code that is hard to maintain and is far from being stuck to the DRY rule:

class ApiController < ApplicationController
  def index
    posts = Post.all
    render json: { posts: posts, success: true }
  rescue PermissionError => e
    render json: { success: false }
  end

  def show
    post = Post.find(params[:id])
    render json: { post: post, success: true }
  rescue PermissionError => e
    render json: { success: false }
  end
end

The rescue_from method is coming to the rescue:

class ApiController < ApplicationController
  rescue_from PermissionError, with: :render_error_response

  def index
    posts = Post.all
    render json: { posts: posts, success: true }
  end

  def show
    post = Post.find(params[:id])
    render json: { post: post, success: true }
  end

  private

  def render_error_response
    { success: false }
  end
end

Do not rescue everything

Avoid code where you rescue from Exception, which is the top-level error class. Such code would be very error-prone, and you will handle any error that will be raised. Instead of this, try to handle as few errors as possible and let the other errors be shown to the world.

Respect the conventions

In many cases, you can meet two versions of one method: some_method and some_method!. In Rails, the banger method indicates that the error will be raised when something would go wrong, and the outcome of the action will be different from the expected one. While in Ruby, the method with banger indicates that the method will alter the object that invoked it:

name = "John Doe"
name.gsub("Doe", "Smith")
# => "John Smith"
name
# => "John Doe"

name.gsub!("Doe", "Smith")
# => "John Smith"
name
# => "John Smith"

Raise or not to raise?

Not every context needs the error to be raised in case something is missed. There are three cases in a typical application or a library when we have to deal with missing things, but each case has its context.

When you request a resource, but it's not available

For example when you call .find method on the ActiveRecord model:

User.find(1)
# => raise ActiveRecord::RecordNotFound

When you try to find an element in the collection

For example, when you have an array of users, and you want to find the one with John Doe name:

User = Struct.new(:name)

users = [User.new("Tim Doe"), User.new("Kelly Doe")]
users.find { |user| user.name == "John Doe" }
# => nil

When you try to find a node in the website's source

For example when you are parsing website source with the Nokogiri gem:

Nokogiri::HTML.parse(nil)
# => #<Nokogiri::HTML::Document:0x13600 name="document" children=[#<Nokogiri::XML::DTD:0x135ec name="html">]>

Nokogiri::HTML.parse(nil).css("a")
# => []

When you query the database

When you search for the record, it's not present, but you can use other criteria as well:

User.where(name: 'John Doe')
# => []

User.where(name: 'John Doe').where(age: 20)
# => []

Context is the king

Don't stick to one approach and instead consider the context before writing the implementation. If you request a record and it's not present in the database, should we raise an error? Yes, we should. If you try to find the element in an array and it's not present, should we raise an error? No, nil is enough.

Sometimes you can keep going even if there are no results. The best example is the Nokogiri case or ActiveRecord - both libraries allow you to chain methods in the call even if one of them is not returning the result you are looking for.

Design pattern to the rescue

There is a null object design pattern that can help you to deal with errors in some cases. You have a User model that can have many addresses assigned, and one of them is current because it has the Address#current column set to true. User can also have no addresses assigned, and in such cases, we would like to render accurate information.

The structure of models in our case looks like the following:

class User < ActiveRecord::Base
  has_many :addresses

  def current_address
    addresses.current
  end
end

class Address
  belongs_to :user

  def current
    find_by(current: true)
  end
end

We can now display the current address full_address value or render accurate information when the address is not found:

user = User.find(...)

if user.current_address.present?
  user.current_address.full_address
else
  'no address'
end

let's refactor it a little bit and use one-liner:

user = User.find(...)
user.current_address&.full_address.presence || 'no address'

Here comes the null object pattern to the rescue:

class EmptyAddress
  def full_address
    'no address'
  end
end

As you can see, our null object is just a simple Ruby object that implements only methods we need to call. We have to modify the User#current_address a little bit:

class User < ActiveRecord::Base
  has_many :addresses

  def current_address
    addresses.current || EmptyAddress.new
  end
end

and our final code is really clean now:

user = User.find(...)
user.current_address.full_address

Monads to the rescue

According to the documentation of the dry-monads gem:

Monads provide an elegant way of handling errors, exceptions, and chaining functions so that the code is much more understandable and has all the error handling, without all the ifs and elses.

Let's give it a try be refactoring our previous code:

require 'dry/monads/all'

class User < ActiveRecord::Base
  include Dry::Monads
  
  has_many :addresses

  def current_address
    addresses.current
  end 
end

user = User.find(...)
current_address = Maybe(user.current_address)
current_address.value_or('no address')

Since the dry-monads gem and the dry-rb family deserves a separate article, I won't explore this topic more. If you are interested in further information, visit the GitHub page https://github.com/dry-rb/dry-monads

Rescuing from multiple errors in an elegant way

Sometimes we have to take into account many different errors and support all of them. It often happens when we create a request class for the external API, and it can throw a bunch of various errors like timeouts or permission ones.

Usually, the code may looks like the below one:

class Request
  def call
    perform_action
  rescue SomeAPI::Error => e
    if e.message.match(/account expired/)
      send_email_with_notification_about_expired_account
    elsif e.message.match(/api key invalid/)
      send_email_with_notification_about_invalid_api_key
    elsif e.message.match(/unauthorized action performed/)
      # log some useful info
    else
      raise(e)
    end
  end
end

It does not look good. It has many if and is not readable at all. One of the methods of refactoring such blocks of code is to overwrite the standard StandardError class and create separated error classes for each case:

class SomeAPIAccountExpired < StandardError
  def self.===(exception)
    exception.class == SomeAPI::Error && exception.message.match(/account expired/)
  end
end

class SomeAPIInvalidAPIKey < StandardError
  def self.===(exception)
    exception.class == SomeAPI::Error && exception.message.match(/api key invalid/)
  end
end

class SomeAPIUnauthorizedAction < StandardError
  def self.===(exception)
    exception.class == SomeAPI::Error && exception.message.match(/unauthorized action performed/)
  end
end

you can now handle it gently:

class Request
  def call
    perform_action
  rescue SomeAPI::Error => e
    handle_api_error(e)
  end

  private

  def handle_api_error(error)
    case error
    when SomeAPIAccountExpired
      send_email_with_notification_about_expired_account
    when SomeAPIInvalidAPIKey
      send_email_with_notification_about_invalid_api_key
    when SomeAPIUnauthorizedAction
      # log some useful info
    else
      raise(error)
    end
  end
end

Such a structure allows you to catch multiple errors from the same source but with a different message that is very readable and meaningful.

Having fun with altering the default Ruby behavior

Since raise is just a method defined on the Kernel, you can overwrite it and do something creative with it:

module MyOwnRaise
  def raise(*args)
    # do something amazing
    super
  end
end

class Object
  include MyOwnRaise
end

raise ArgumentError # let's have some fun

For example, you can create the code to collect and count the errors and then present the summary in a friendly dashboard inside your app.

Special thanks to the following authors for the inspiration for the article: