Why it is Important to dig in Deep

#Ruby, #Rails, #Code Philosophy


Skylight is a great service for monitoring and profiling Rails applications (we’re using it in several of our projects). Recently they published a blog post about upgrading their application to Rails 5. Reading this post I stumbled about one particular detail that puzzled me. Here’s the story:

One of the errors they faced during the upgrade was caused by this code (slightly simplified):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class CustomersController < BlingBling::ApplicationController
  rescue_from ::Stripe::CardError, with: :invalid_card, only: [:update, :update_card]

  def show
    # stuff
  end

  def update
    # other stuff
  end

  def update_card
    # yet moar stuff
  end

  private

  def invalid_card
    render_errors card: "is invalid"
  end
end

Line 2 raises ArgumentError: unknown keyword: only. It looks like Rails 5 no longer supports this particular option for #rescue_from. A quick glance at the documention seems to confirm this: No mention of only: (and none of except: either, eventhough they are always used together).

So the team at Skylight decided to work around this now missing option. It just takes a few additional lines of code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class CustomersController < BlingBling::ApplicationController
  def show
    # stuff
  end

  def update
    handle_invalid_card {
      # other stuff
    }
  end

  def update_card
    handle_invalid_card {
      # yet more stuff
    }
  end

  private

  def handle_invalid_card
    begin
      yield
    rescue ::Stripe::CardError => error
      render_errors card: "is invalid"
    end
  end
end

Problem solved! Except for … that it’s not. In fact, this change introduces a bug. The updated coded behaves differently than the original code. To understand why, we need to dig in deeper.

Let’s Dig

First we take a look at the documentation entry for #rescue_from in the previous Rails version (4.2):

Rescue exceptions raised in controller actions.

rescue_from receives a series of exception classes or class names, and a trailing :with option with the name of a method or a Proc object to be called to handle them. Alternatively a block can be given. […]

Hmm, no mention of :only or :except here either, that’s strange. Maybe they had been deprecated? Let’s take a look at the source:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def rescue_from(*klasses, &block)
  options = klasses.extract_options!

  unless options.has_key?(:with)
    if block_given?
      options[:with] = block
    else
      raise ArgumentError, "Need a handler. Supply an options hash that has a :with key as the last argument."
    end
  end

  klasses.each do |klass|
    key = if klass.is_a?(Class) && klass <= Exception
      klass.name
    elsif klass.is_a?(String)
      klass
    else
      raise ArgumentError, "#{klass} is neither an Exception nor a String"
    end

    # put the new handler at the end because the list is read in reverse
    self.rescue_handlers += [[key, options[:with]]]
  end
end

No, nothing: Even in Rails 4.2, #rescue_from does not support the :only or :except options. But it doesn’t complain about them either. They are just silently ignored.

The Root of the Bug

To repeat: In Rails 4.2, using :only or :except together with #rescue_from has no effect whatsoever.

And that’s why the upgrade introduced a bug: In the original version of the code, eventhough it suggested otherwise, the #rescue_from clause affected all controller actions. After the upgrade, the clause now affects some actions only.

Of course this bug is not severe, and it may not be a problem at all. But it’s an unintended change of behaviour nonetheless. If at all possible, do not introduce behavioural changes when upgrading your code.

(Fun fact: One could argue that the original code already had a bug – the #rescue_from should not have affected all actions –, and that by introducing another bug while incorrectly replicating the original behaviour this has now been fixed. Unintentional bugfixing ftw.)

Summary

Here’s what happened in a nutshell:

  • While upgrading the framework, a piece of code stopped working.
  • The failing code was analysed to understand its behaviour.
  • A workaround was introduced, implementing the very same behaviour in a way compatible with the framework’s new version.

So far, so good. However:

  • The analysis of the original code was incomplete.
  • Thus the workaround was implemented based on a wrong understanding of the original behaviour.
  • Consequently, the workaround now behaves differently than the original code.

How to not Introduce Behavioural Changes Inadvertently

Not introducing unintended behavioural changes requires you to to completely understand the current behaviour. In some cases (like in this example), code may behave differently from what it looks like at first glance. To develop a thorough understanding you need to dig in deep.

And: Don’t be afraid to look at the source code of frameworks and libraries. It helps. Documentation may be incomplete our outdated, but the source code never is.


Post Scriptum: Option Hashes vs. Keyword Arguments

Are you wondering why the original code started to fail in Rails 5? Let’s dig into this, too. Here is the definition and first line of #rescue_from in Rails 4.2:

1
2
3
4
5
# Rails 4.2
def rescue_from(*klasses, &block)
  options = klasses.extract_options!
  # …
end

The method uses an array/hash argument: It accepts any number of arguments and stores it as an array in klasses (let’s ignore the &block argument for now):

1
2
3
rescue_from(Foo)       # => klasses is [Foo]
rescue_from(Foo, :bar) # => klasses is [Foo, :bar]
rescue_from()          # => klasses is []

But what happens when we call the method like this: rescue_from(Foo, with: bar)? Well, there’s this particular nice syntactic feature in Ruby:

The array argument will capture a Hash as the last entry if a hash was sent by the caller after all positional arguments.

Some examples to clarify:

1
2
3
rescue_from(Foo, with: :bar)                 # => klasses is [Foo, {with: :bar}]
rescue_from(Foo, :bar, with: :baz, and: 123) # => klasses is [Foo, :bar, {with: baz, and: 123}]
rescue_from(with: :bar)                      # => klasses is [{with: :bar}]

If the last argument looks like a hash, is is treated as such and added to the end of the array. The method then uses #extract_options! (documentation) to extract this hash from the array. This is a commonly used idiom in Ruby.

However, in Ruby 2.0 keyword arguments were introduced. And #rescue_from has been updated accordingly in Rails 5:

1
2
3
4
# Rails 5 (note the explicit keyword argument `with:`)
def rescue_from(*klasses, with: nil, &block)
  # …
end

With the introduction of keyword arguments, the behaviour of array/hash arguments changed in one important way (emphasis mine):

The array argument will capture a Hash as the last entry if a hash was sent by the caller after all positional arguments. However, this only occurs if the method does not declare any keyword arguments.

So because in Rails 5 #rescue_from does declare with: as keyword argument, it is no longer possible to call the method with any other hash-like/keyword arguments:

1
2
rescue_from(Foo, with: :bar)           # => klasses is [Foo], with is :bar
rescue_from(Foo, with: :baz, and: 123) # => ArgumentError: unknown keyword: and

And that’s why the original line of code started to fail in Rails 5:

1
2
rescue_from ::Stripe::CardError, with: :invalid_card, only: [:update, :update_card]
  # => ArgumentError: unknown keyword: only

We offer Ruby on Rails consulting based on over 11 years of experience, going back to Rails 0.9.3 in 2005.

Want to know more?