logo <

Interesting throw/catch behaviour in Ruby

When I was working on integrating Rodauth with OmniAuth authentication, I noticed an error warning after upgrading to Rails 6.1, when Rodauth was redirecting inside a Rails controller action:

class RodauthController < ApplicationController
  def omniauth
    # ...
    rodauth.login("omniauth") # logs the session in and redirects
  end
end
Could not log "process_action.action_controller" event.
/path/to/actionpack-6.1.1/lib/action_controller/log_subscriber.rb:26:in `block in process_action': undefined method `first' for nil:NilClass (NoMethodError)

Since I want the integration between Rodauth and Rails to be as smooth as possible, I decided to investigate.

Diving in

Let’s see the ActionController::LogSubscriber source code where the error happens:

# lib/action_controller/log_subscriber.rb
module ActionController
  class LogSubscriber < ActiveSupport::LogSubscriber
    # ...
    def process_action(event)
      # ...
        status = payload[:status]

        if status.nil? && (exception_class_name = payload[:exception].first) # <==== the exception happens here
          status = ActionDispatch::ExceptionWrapper.status_code_for_exception(exception_class_name)
        end
      # ...
    end
    # ...
  end
end

We can see that the issue happens because :exception data is missing from the instrumentation event payload. Let’s look at ActionController::Instrumentation next, which is in charge of instrumenting controller actions:

# lib/action_controller/metal/instrumentation.rb
class ActionController
  module Instrumentation
    def process_action(*)
      # ...
      ActiveSupport::Notifications.instrument("process_action.action_controller", raw_payload) do |payload|
        result = super # <=== this calls our controller action
        payload[:response] = response
        payload[:status]   = response.status
        # ...
      end
    end
  end
end

We can see that, if our controller action raises an exception, the :status data will never be set. This ties to the status.nil? check we’ve seen in the ActionController::LogSubscriber.

The remaining part is to find where :exception is being set. Knowing that instrumentation is implemented in Active Support, I quickly found ActiveSupport::Notifications::Instrumenter:

# lib/active_support/notifications/instrumenter.rb
module ActiveSupport
  module Notifications
    class Instrumenter
      # ...
      def instrument(name, payload = {})
        # ...
        begin
          yield payload if block_given?
        rescue Exception => e
          payload[:exception] = [e.class.name, e.message] # <==== the exception is set here
          payload[:exception_object] = e
          raise e
        ensure
          finish_with_state listeners_state, name, payload
        end
      end
    end
  end
end

The problem

When Rodauth redirects, what is actually doing is throwing :halt with the rack response. This is how Roda implements redirection, and it’s common practice in non-Rails web frameworks (Sinatra and Cuba do it too). In our case, throwing exits from controller action and is caught by the Roda middleware.

Does throwing act the same way as raising an exception does? Initially it would appear so:

begin
  throw :halt
rescue Exception => exception
  puts "rescue: #{exception.inspect}"
  raise
ensure
  puts "ensure"
end
rescue: #<UncaughtThrowError: uncaught throw :halt>
ensure
~> uncaught throw :halt (UncaughtThrowError)

This makes sense to me, because uncaught throw is an exception. But then why wasn’t the rescue block that was supposed to set the :exception in the event payload being executed?

The picture starts getting clearer when we wrap the code with a catch block:

catch(:halt) do
  begin
    throw :halt
  rescue Exception => exception
    puts "rescue: #{exception.inspect}"
    raise
  ensure
    puts "ensure"
  end
end
ensure

We see that in this case the rescue block isn’t being executed, and this is precisely our scenario. This actually makes sense when you think about it, because a throw with a matching catch is not anything erroneous, it’s just a way to do an early return.

The solution

Now we know where the issue is, which is that Rails just wasn’t correctly handling a throw/catch scenario when processing controller actions. Fixing it was the easy part.

throw/catch is probably something you’ll rarely use, but it does have its use cases. I hope this article taught you a bit more about this lesser known Ruby feature.

Janko Marohnić