by Kevin Murphy
Exceptional Behavior in Rails
- (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, 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
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
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!