by Kevin Murphy
Exceptional Behavior in Rails
- (W)rapping About Exceptional Behavior In Rails
- Wrapping Up Rails Exceptional Behavior
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
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
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
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
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!
Learn more about how The Gnar builds Ruby on Rails applications.