Wrapping Up Rails Exceptional Behavior

Kevin Murphy - Mar 28 '21 - - Dev Community

Reset

In our last post, we encountered some inconsistent behavior between Rails 5 and Rails 6. In Rails 5, raising a RuntimeError in a controller after rescuing from an ActiveRecord::RecordNotFound exception was still returning a 404 HTTP status code. In Rails 6, the status code is a 500.

We looked around, and we think we've isolated the area of interest to be in the ExceptionWrapper class.

Revisit The Wrapper

We looked into what was creating our wrapper and discovered that we were always passing it the RuntimeError. After taking a much-needed break, we start reading the code again, and, almost immediately, we see a transformation:

def initialize(backtrace_cleaner, exception)
  @backtrace_cleaner = backtrace_cleaner
  @exception = original_exception(exception)
end
Enter fullscreen mode Exit fullscreen mode

The exception that is passed in is modified. Let's look at this original_exception method.

def original_exception(exception)
  if @@rescue_responses.has_key?(exception.cause.class.name)
    exception.cause
  else
    exception
  end
end
Enter fullscreen mode Exit fullscreen mode

Recall that our RuntimeError is raised as a result of handling an ActiveRecord::RecordNotFound exception. The RecordNotFound exception is the cause of the RuntimeError. We previously discovered that RecordNotFound is added to @@rescue_responses in ActiveRecord's railtie.

The cause of our exception is in the hash, and as such, the cause is set as the @exception variable in the initializer. That cause is RecordNotFound, and a RecordNotFound exception is supposed to return a 404 status code.

We can now explain why a 404 is returned!

Regifting (Rails 6 Redux)

We now have a handle on the behavior in Rails 5; however, this investigation started because we noticed it was different in Rails 5 and Rails 6. Let's check in on the ExceptionWrapper initializer in Rails 6.

def initialize(backtrace_cleaner, exception)
  @backtrace_cleaner = backtrace_cleaner
  @exception = exception
end
Enter fullscreen mode Exit fullscreen mode

No longer are we retrieving the original_exception. That doesn't tell the whole story though. When we ask for the status code, we're not using @exception. Instead, we now have an unwrapped_exception to investigate.

def unwrapped_exception
  if wrapper_exceptions.include?(exception.class.to_s)
    exception.cause
  else
    exception
  end
end
Enter fullscreen mode Exit fullscreen mode

Rather than looking in rescue_responses, we're now looking in wrapper_exceptions, which it appears is a list of one exception that should
behave particularly exceptionally.

If the exception is an ActionView::Template::Error, then look up the status code based on the cause of the exception. Otherwise, determine it based on the exception itself.

RuntimeError isn't in this list of wrapper_exceptions, so we don't use the cause (ActiveRecord::RecordNotFound) to determine the status code. We use the RuntimeError itself. That has no special handling in rescue_responses, so a 500 HTTP status code is returned.

Thank You Card

The commit that makes this change contains a very well-worded description of this scenario, including:

When the cause is mapped to an HTTP status code the last exception is unexpectedly uwrapped

Thanks to Yuki Nishijima for fixing this!

This post originally published on The Gnar Company blog.

Learn more about how The Gnar builds Ruby on Rails applications.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .