← All Articles

Advanced Ruby exception handling

Dimitrios LisenkoDimitrios Lisenko
Nov 27th 18Updated Jul 5th 22
Ruby on Rails

advanced-ruby-exception-handling

So, how many times have you, while investigating an (admittedly rare ;)) production bug, stumbled upon an exception backtrace that just doesn't have enough information? For example, an exception gets reraised, destroying the original backtrace with it. So now your logs say that your AI failed to take over the world with AIEpicFailError (great, you already know that), but not why (Timeout::Error while connecting to stackoverflow.com).

There are numerous suggestions online - one is to reraise the exception, but modify the exception first to preserve the original backtrace. That works, but you lose the original exception type, as well as the backtrace of this exception. Another is to log the backtrace before reraising at every point in the application - but this means that your logs now have multiple backtraces and it's not obvious which ones are part of the same flow. None of these approaches sit well with me.

While we were having heated internal engineering arguments about how we could implement a chain of exceptions (e.g. with thread local storage - yuck), I found out that in fact, there is an Exception#cause method in Ruby since version 2.1! This means that something like this is possible:

class A < StandardError; end
class B < StandardError; end

the_exception = nil
begin
  begin
    raise A
  rescue
    raise B
  end
rescue => exc
  the_exception = exc
end

the_exception # #<B: B>
the_exception.cause # #<A: A>
the_exception.cause.cause # nil

My GOD! This is amazing! This means that you can simply reraise an exception in a rescue block, and ruby automatically keeps a chain of what caused each preceding exception. Honestly, I have no idea why no one is talking about this, and why it took me so long to find it online.

So you get hyped, but then maybe you realize your application has multithreading. The multithreading library we use will execute a bunch of lambdas in a thread pool. You can then probe whether these lambdas were successful, or get the associated exception if there is one. However, this means you now have an array of exceptions. Is your beautiful Exception#cause approach doomed?

Fear not, for I have the solution!

class StandardErrorCollection < StandardError
  include Enumerable # Enumerable gives you a bunch of free methods (to_a, select, map...) as long as you implement each - awesome!
  attr_reader :causes

  def initialize(causes)
    @causes = causes
  end

  def each(&block)
    @causes.each(&block)
  end
end

This creates a StandardErrorCollection, which itself is a StandardError, meaning you can raise it. It is also Enumerable, which means you can iterate through it. With the above implementation, it will iterate through the causes of this exception. Here's example usage:

class A < StandardError; end

the_exception = nil
begin
  raise A
rescue => exc
  the_exception = exc
end

begin
  raise StandardErrorCollection.new(3.times.map { the_exception }) # pass an array of exceptions
rescue => exc
  the_exception = exc
end
the_exception.causes # [#<A: A>, #<A: A>, #<A: A>]

Great! This resolves the multithreading issue - you now have an exception which was caused by multiple causes, and you can continue raising this single exception up the stack.

So now you have arrived at the top of your stack, and it's time to log the backtraces. You have a single exception, which encompasses a chain of exceptions, with each one having either zero, one, or multiple causes. This sounds a lot like a tree to me! This means we can use a depth first search in order to find all the unique paths from root to leaf, and this gives us all the unique code execution paths. We can then log each path as a single log entry. So good, I might cry.

Here's an implementation for a path traverser, which is built on top of a depth first search:

# this is a base class since there multiple types of traversal - path traversal, depth first search traversal, breadth first search traversal...
class ExceptionTraverser
  include Enumerable
  attr_reader :exception

  def initialize(exception)
    @exception = exception
  end
end

class ExceptionPathTraverser < ExceptionTraverser
  def each(&block)
    return if @exception.nil?
    each_internal(@exception, [], &block)
  end

  private

  def each_internal(exception, path, &block)
    # NOTE: path.clone is required because otherwise to_a will return an empty array (since path will be an empty array after everything has executed)
    # NOTE: path.clone will clone the array, but not the exceptions, so duplicate exceptions will be the same object ID - awesome!
    return block.call(path.clone) if exception.nil?
    path.push(exception)
    if exception.respond_to?(:causes)
      exception.causes.each do |cause|
        each_internal(cause, path, &block)
      end
    else
      each_internal(exception.cause, path, &block)
    end
    path.pop
  end
end

You will note that ExceptionTraverser is also Enumerable - this will allow us to enumerate through whatever it is we are traversing.

For ExceptionPathTraverser, we are traversing paths. To achieve this, the class simply implements the each method required for the object to be Enumerable, and yields the individual paths.

Example usage follows - we will be generating the following exception tree with the execution paths (E->F->C->B->A) and (E->F->D->C):
cloud66-exception-tree

class A < StandardError; end
class B < StandardError; end
class C < StandardError; end
class D < StandardError; end
class E < StandardError; end
class F < StandardErrorCollection; end

def capture_exception
  captured_exception = nil
  begin
    yield
  rescue => exc
    captured_exception = exc
  end
  return captured_exception
end

def reraise_with(exc)
  begin
    yield
  rescue
    raise exc
  end
end

# pseudo execution of a multithreader which returns an array of exceptions
exception_on_thread_one = capture_exception { reraise_with(C) { reraise_with(B) { raise A } } }
exception_on_thread_two = capture_exception { reraise_with(D) { raise C } }
all_thread_exceptions = [exception_on_thread_one, exception_on_thread_two]
final_exception = capture_exception { reraise_with(E) { raise F.new(all_thread_exceptions) } }

ExceptionPathTraverser.new(final_exception).each do |path|
  puts path.inspect
end
# [#<E: E>, #<F: F>, #<C: C>, #<B: B>, #<A: A>]
# [#<E: E>, #<F: F>, #<D: D>, #<C: C>]

You can now take each execution path, and log all the associated backtraces, as well as any additional information in a single log entry.

Hope you enjoyed this blog post, and find it useful in your endeavours!

UPDATE : this is now available as a gem that you can use in your ruby applications!


Try Cloud 66 for Free, No credit card required