loading...
Cover image for Custom Exception Handling in Ruby
Bearer.sh

Custom Exception Handling in Ruby

markmichon profile image Mark Michon Originally published at blog.bearer.sh ・6 min read

📣 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.

Posted on by:

markmichon profile

Mark Michon

@markmichon

Developer, accidental educator, and erstwhile designer.

Bearer.sh

Bearer is a developer tool that helps engineering teams manage &amp; monitor third-party APIs, detect anomalies and remediate issues.

Discussion

markdown guide