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: