📣 This post originally appeared as Custom Exception Handling in Ruby on The Bearer Blog.
In Ruby, like in most languages, an exception is a way to convey that something went wrong. While some languages only use exceptions for truly exceptional circumstances, like run-time errors, Ruby uses exceptions for a wide variety of errors and unexpected results.
In this article, we will look at:
- How to handle common errors
- How to react to a specific exception
- How to create your own custom exceptions
How to raise and rescue exceptions in Ruby
Your own functions, Ruby, or gems and frameworks can raise exceptions internally. If you are familiar with Javascript of a C-inspired language, you may know of the try/catch
concept. Ruby has something similar for handling exceptions, using the begin-end
block. It can contain one or more rescue
clauses. Let's look at an example:
begin
# Your code
cause_an_error()
rescue
puts "An error has occurred"
end
This is one way to build a basic ruby exception handler. This is also a way to catch common errors, as rescue
without any arguments will catch any StandardError
and its subclasses.
You can also assign the exception to a local variable, using rescue => var_name
, to gain access to properties of the exception. For example:
# ...
rescue => error
puts "#{error.class}: #{error.message}"
end
We said that rescue
on its own catches StandardError
and its subclasses. You can also specify the error type you'd like to target. This allows you to use multiple rescue
clauses. For example:
begin
# do something that raises an exception
do_something()
rescue NameError
puts "A NameError occurred. Did you forget to define a variable?"
rescue CustomError => error
puts "A #{error.class} occurred: #{error.message}"
end
In this example, the multiple rescue blocks handle each error type separately.
You can also rescue multiple error types at the same time by separating them with a comma. For example:
# ...
rescue NameError, CustomError, ArgumentError => error
# handle errors
end
In these examples, we use begin
and end
. You can also use rescue
inside a method or block without the need for begin and end. For example:
def my_function
perform_action()
rescue => error
puts error.message
end
Else and ensure
In addition to begin-end
and rescue
, Ruby offers else
and ensure
. Let's look at an example:
begin
# perform some actions
rescue => error
puts "Handle the #{error.class}"
else
# Code in the else block will run if no exception occurs
do_someting_else()
ensure
# Code in the ensure block will always run
always_do_this()
end
Any code in the else
block will run if no exceptions occur. This is like a "success" block. Code in the ensure
block will run every time, even if the code does not throw an exception.
To summarize:
- The code in
begin
will try to run. - If an error occurs and throws an exception,
rescue
will handle the specified error types. - If no error occurs, the
else
block will run. - Finally,
ensure
's block will run always.
Raising specific exceptions
Now that we know how to handle, or rescue, errors and exceptions we can look at how to raise or throw an exception. Every exception your code rescues was raised at some location in the stack.
To raise a StandardError
:
raise StandardError.new('Message')
# OR
raise "Message"
Since StandardError
is the default exception type for raise
, you can omit creating a new instance and instead pass the message on its own.
Let's look at this in the context of a function and a begin-end
block.
def isTrue(x)
if x
puts "It's true"
else
raise StandardError.new("Not a truthy value")
end
end
begin
isTrue(false)
rescue => error
puts error.message
end
Here we have an isTrue
function that takes an argument (x) and either puts
if x is true or raises an error if it is false. Then, in the begin block rescue
ensures that the code recovers from the raise
within isTrue
. This is how you will commonly interact with errors. By recovering from a raise
that occurred in another part of your codebase.
Creating custom exceptions
If you are building a gem or library, it can be useful to customize the types of errors your code raises. This allows consumers of your code to rescue based on the error type, just as we've done in the examples so far. To create your own exceptions, it is common to make them subclasses of StandardError
.
class MyCustomError < StandardError; end
Now, you can raise MyCustomError
when necessary, and your code's consumers can rescue MyCustomError
.
You can also add properties to custom exception types just as you would any other class. Let's look at an example of a set of errors for a circuit breaker. The circuit breaker pattern is useful for adding resiliency to API calls. For our purposes, all you need to know is that there are three states. Two of them may cause an error.
Let's create a custom error that, instead of just taking an error message, also takes the state
of our circuit.
class CircuitError < StandardError
def initialize(message, state)
super(message)
@state = state
end
attr_reader :state
end
The new CircuitError
class inherits from StandardError
, it passes message
to the parent class and makes state
accessible to the outside.
Now, if we look at this in the context of a rescue
, we can see how it might be used.
begin
raise CircuitError.new("The circuit breaker is active", "OPEN")
rescue CircuitError => error
puts "#{error.class}: #{error.message}. The STATE is: #{error.state}."
# => CircuitError: The circuit breaker is active. The STATE is: OPEN.
end
The rescue block can now take advantage of the added state
property that exists on the error instance.
Adding custom exceptions to your modules
If you are developing a module, you can also take this a step further by incorporating custom error types into the module. This allows for better name-spacing and makes it easier to identify where the error is coming from. For example:
module MyCircuit
# ...
module Errors
class CircuitError < StandardError; end
end
# ...
end
You can raise the error type as follows:
raise MyCircuit::Errors::CircuitError.new('Custom error message')
It is common for libraries to include a set of subclassed error types. Generally, these live in exceptions.rb
or errors.rb
file within the library.
Rescue exceptions based on the parent class
So far we've seen how to rescue exceptions, raise exceptions, even create our own. One more trick is the ability to recover from errors based on their parent class.
Let us take our circuit breaker error from earlier and split it into one parent and two children. Rather than require the raise
clauses to pass in arguments, we will handle that in the errors themselves.
class CircuitError < StandardError
def initialize(message)
super(message)
end
end
class CircuitOPEN < CircuitError
def initialize
super('The Circuit Breaker is OPEN')
end
end
class CircuitHALF < CircuitBreaker
def initialize
super('The Circuit Breaker is HALF-OPEN')
end
end
Here, both CircuitOPEN
and CircuitHALF
are subclasses of CircuitError
. This may not seem useful, but it allows us to check for either error individually, or all subclasses of CircuitError
. Let's see what that looks like.
To rescue
them individually would look like:
begin
# ...
raise CircuitOPEN
rescue CircuitHALF
# do something on HALF error
rescue CircuitOPEN
# do something on OPEN error
end
We could also group them together:
rescue CircuitHALF, CircuitOPEN
# do something on both
But even better, since they are both subclasses of CircuitError
, we can rescue them all by rescuing CircuitError
.
begin
raise CircuitHALF
# or raise CircuitOPEN
rescue CircuitError
# will catch both HALF or OPEN
end
By utilizing this method, your code can react to types of errors in addition to specific errors.
How to manage errors
This approach to handling exceptions in ruby goes a long way toward providing valuable errors to consumers of your code. Not only can you throw and raise exceptions, but your users can now catch exceptions in their ruby code that may come from your library.
At Bearer, we use an approach similar to this in our Agent codebase to provide our users with error details that help them identify where the problem is coming from.
If you're interested in preventing errors in your codebase, check out what we're building at Bearer. The Bearer Agent makes handling API inconsistencies easier by offering automatic remediations, resiliency patterns, and notifications with no changes to your code.
Explore more on the Bearer Blog and connect with us @BearerSH.