Wrapping Up Rails Exceptional Behavior
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!
This post originally published on The Gnar Company blog.