- (W)rapping About Exceptional Behavior In Rails
- Wrapping Up Rails Exceptional Behavior
In our last post, we
encountered some inconsistent behavior between Rails 5 and Rails 6. In Rails 5,
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
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
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
@exception variable in the initializer. That cause is
RecordNotFound exception is supposed to return a 404 status code.
We can now explain why a 404 is returned!
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
RuntimeError isn't in this list of
wrapper_exceptions, so we don't use the
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.
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!