Caller-specified callback in Ruby

Here’s an interesting technique I’d like to see more of in the wild: passing in error handlers using callbacks. For that matter, I’m a fan of callbacks in general, but that’s probably my c programming background showing through.


Note: the basis for the code shown here is one of Avdi Grimm’s free Ruby Tapas screencasts. You should watch it, it’s good.


So let’s take a look at BookFetcher, which examines our extensive library of technical books. Because as programmers, so many of us are addicted to purchasing technical books, we have what many people might consider a library. And sometimes we need to find books in that library. Consider the following, simplistic snippet, a wrapper around Hash.fetch:

#!/usr/bin/env ruby

class BookFetcher
  DEFAULT_FALLBACK = ->(error) { raise }

  def fetch hash, &fallback
    fallback ||= DEFAULT_FALLBACK
    hash.fetch(:book)
  rescue => error
    fallback.call(error)
  end
end

The actual “fetching” is a no-op, we’re only interested in how the callback interacts with Hash.fetch.

Also, note that we’re effectively reimplementing the error handling behavior of Hash.fetch itself. The example is a bit contrived and sort of meta.

Next, let’s define a couple of custom errors to play with.

require 'rspec'

class LibraryError < StandardError
end

class LibraryFancyError < LibraryError
  attr_reader :book
  def initialize book
    @book = book
    super "The book '#{@book}' is not in your library" unless book.nil?
  end
end

Now let’s run this code to see what it does. RSpec is a nice way to investigate behavior, starting with a spec I always write before getting started, namely, one that tests for the existence and default instantiation of a new class.

describe BookFetcher do
  it "instantiates a new BookFetcher" do
    tf = BookFetcher.new
    expect(tf).to_not be_nil
  end

Ok so that seems dumb, and is redundant given the following specs, but I’m leaving it to show that’s it just fine to write dumb tests when driving out new code. Such tests can always be deleted later.

Almost as dumb, but probably not redundant, is a test to ensure the method actually works as designed. That is, let’s not throw any errors:

  it "fetches a 'Confident Ruby' book" do
    tf = BookFetcher.new
    book = tf.fetch book: 'Confident Ruby'
    expect(book).to eq 'Confident Ruby'
  end

Once we’re satisfied we have a working implementation, we can get on with the main event, error handling. First is ensuring we catch the default error produced by Hash when it can’t find a key. The fallback simply passes through the error raised from Hash.

  it "raises Hash error on wrong hash key" do
    tf = BookFetcher.new
    expect {
      book = tf.fetch foobar: 'Confident Ruby'
    }.to raise_error "key not found: :book"
  end

Now let’s customize with a basic LibraryError:

  it "raises an error with default message on wrong key" do
    tf = BookFetcher.new
    expect {
      book = tf.fetch foobar: 'Confident Ruby' do |error|
        raise LibraryError, error.message
      end
    }.to raise_error "key not found: :book"
  end

Library doesn’t do anything more than “tag” the underlying Hash error and again, pass it along in the same way as the fallback error.

We can check for LibraryError explicitly by not passing along the raised error message, like so:

  it "raises a LibraryError without message on wrong key" do
    tf = BookFetcher.new
    expect {
      book = tf.fetch foobar: 'Confident Ruby' do |error|
        raise LibraryError
      end
    }.to raise_error "LibraryError"
  end

And the message can be explicitly suppressed by passing in nil:

  it "raises a LibraryError with suppressed message on wrong key" do
    tf = BookFetcher.new
    expect {
      book = tf.fetch foobar: 'Confident Ruby' do |error|
        raise LibraryError, nil # suppress message
      end
    }.to raise_error 'LibraryError'
  end

Always reassuring to see the code works as advertised. This may or may not be useful, but these specs demonstrate what happens under the given conditions, which I find useful for understanding.

Now let’s get fancier with a LibraryFancyError.

  it "raises a LibraryFancyError with custom message" do
    tf = BookFetcher.new
    book = 'Confident Ruby'
    expect {
      book = tf.fetch foobar: 'Confident Ruby' do
        raise LibraryFancyError, book
      end
    }.to raise_error "The book '#{book}' is not in your library"
  end

Finally, it’s perfectly legimate in Ruby to define Procs elsewhere, and pass such Procs as the trailing argument to methods requiring or allowing blocks. Simply ensure the named Proc is prefixed with an ampersand &, as shown below:

  it "raises a LibraryFancyError with custom message as Proc" do
    tf = BookFetcher.new
    book = 'Confident Ruby'
    custom_error = Proc.new { raise LibraryFancyError, book }
    expect {
      book = tf.fetch foobar: 'Confident Ruby', &custom_error
    }.to raise_error "The book '#{book}' is not in your library"
  end
end

How useful is this?

An obvious criticism at this point: “This technique smacks of overengineering. The custom error method should be specified in the rescue without all this callback Mickey Mouse.”

For this particular snippet, without any further context to an underlying problem, such criticism is probably true. I can’t think of any reason whatsoever to employ such error handling in the current production code at work. However, it could be useful for directing logging statements to appropriate output streams.

For example, I am using this technique to abstract output formatting on a data processing stream. The data processor (which is in a Ruby gem) has no business knowing how to format any output. The output formatter is passed in as a writer callback. The gem provides a default callback to output csv, but the writer is just as happy to write to a postgres database, just pass in the appropriate callback.


Tags