Rails の rescue_from で Exception や StandardError をキャッチすることは推奨されていない
rescue_from で Exception
をキャッチすると予期しない挙動をして驚いたことがあった。Rails ガイドを見てみると以下のようにそのような使い方は非推奨のようだった。
Action Controller Overview — Ruby on Rails Guides
Using rescue_from with Exception or StandardError would cause serious side-effects as it prevents Rails from handling exceptions properly. As such, it is not recommended to do so unless there is a strong reason.
Exception や StandardError を指定すると Rails による正しい例外ハンドリングが阻害されて深刻な副作用が起こる可能性がある。強い理由がない限りはそうしないほうがよい。
rescue_from のおさらい
ActiveSupport::Rescuable::ClassMethods の通りで、例えば次のように記述すると、指定した例外を指定したハンドラで処理できる。User::NotAuthorized
が発生すると deny_access が、ActiveRecord::RecordInvalid
には show_record_errors が、MyApp::BaseError
にはその場で登録している処理が対応する。
class ApplicationController < ActionController::Base
rescue_from User::NotAuthorized, with: :deny_access
rescue_from ActiveRecord::RecordInvalid, with: :show_record_errors
rescue_from "MyApp::BaseError" do |exception|
redirect_to root_url, alert: exception.message
end
private
def deny_access
head :forbidden
end
def show_record_errors(exception)
redirect_back_or_to root_url, alert: exception.record.errors.full_messages.to_sentence
end
end
評価は下から上にされる
これは ドキュメント にも記載されているが、登録したハンドラは下から上の順に検索され、例外がマッチしたらそこで終了する。マッチするかどうかは exception.is_a?(klass)
でチェックする。
Handlers are inherited. They are searched from right to left, from bottom to top, and up the hierarchy. The handler of the first class for which exception.is_a?(klass) holds true is the one invoked, if any.
例えば次のように記載すると、例外はまず is_a StandardError かどうか (StandardError 及びそのサブクラスかどうか) をチェックされる。ほとんどのケースでここでマッチするので、実質的に :deny_access
, :show_record_errors
を通ることは無くなる。よって範囲の広い例外をキャッチする場合は「上」に記載する必要がある。
class ApplicationController < ActionController::Base
rescue_from User::NotAuthorized, with: :deny_access
rescue_from ActiveRecord::RecordInvalid, with: :show_record_errors
rescue_from StandardError, with :other_error_handler
# ...
end
実装 は次のようになっている。
- rescue_handlers は
rescue_from
で記載したハンドラが、登録順に入っている - これを
reverse_each
で操作している。つまり「下から上へ」調べていくという挙動になっている
def find_rescue_handler(exception)
if exception
# Handlers are in order of declaration but the most recently declared
# is the highest priority match, so we search for matching handlers
# in reverse.
_, handler = rescue_handlers.reverse_each.detect do |class_or_name, _| # ここで reverse_each している
if klass = constantize_rescue_handler_class(class_or_name)
klass === exception
end
end
handler
end
end
マッチする例外がない場合 cause を取り出して再度評価する
知る限りはドキュメントには記載がないが、発生した例外にマッチするハンドラがなかった場合 cause でラップされた元の例外を取り出して再度すべてのハンドラを調査するという挙動をする。
例えば MySQL を使っているアプリケーションで DB インスタンスとの接続エラーが起きた場合を考える。その場合、まずは Mysql2::Error::ConnectionError
が投げられるが、ActiveRecord の ConnectionAdapters は ActiveRecord::ConnectionNotEstablished でそれをラップしている。
ここでコントローラでは次のように Mysql2::Error::ConnectionError
を捕捉しているとする。
class ApplicationController < ActionController::Base
rescue_from User::NotAuthorized, with: :deny_access
rescue_from ActiveRecord::RecordInvalid, with: :show_record_errors
rescue_from Mysql2::Error::ConnectionError, with: :show_internal_server_error
# ...
end
その場合次のような挙動になる。
- まず
ActiveRecord::ConnectionNotEstablished
に対して対応するハンドラを探す - マッチするものがないので
cause
を呼び出し、Mysql2::Error::ConnectionError
が得られる Mysql2::Error::ConnectionError
に対して、再度ハンドラを探す- 最初の
:show_internal_server_error
が該当するのでこちらに処理が任される
実装 は次のようになっている。
visited_exceptions
に確認済みの例外をメモしながらハンドラをチェックしていく- ハンドラが見つからず、かつ
exception.cause
がチェック済みでない場合、再帰的に rescue_with_handler を呼び出して再度はじめから検索している
def rescue_with_handler(exception, object: self, visited_exceptions: [])
visited_exceptions << exception
if handler = handler_for_rescue(exception, object: object)
handler.call exception
exception
elsif exception
if visited_exceptions.include?(exception.cause)
nil
else
rescue_with_handler(exception.cause, object: object, visited_exceptions: visited_exceptions) # 見つからなかった場合は exception.cause を取り出して再帰的に rescue_with_handler を呼び出す
end
end
end
Exception を指定するとどうなるか
以上の挙動を踏まえると、Rails ガイドが注意喚起しているように、下手に Exception や StandardError を rescue_from しようとすると、意図しない挙動につながってしまう恐れがあるため、よく注意する必要がある。
ハンドラの登録順
まずは rescue_from でハンドラを登録する順番に注意する必要がある。前述のように「下」で Exception を指定するとすべての例外がそのハンドラに渡ってしまい、:deny_access
、:show_record_errors
を通ることは無くなってしまう。
class ApplicationController < ActionController::Base
rescue_from User::NotAuthorized, with: :deny_access
rescue_from ActiveRecord::RecordInvalid, with: :show_record_errors
rescue_from StandardError, with :other_error_handler # ほとんどの例外がここに吸収される
# ...
end
設定するとしたら「上」に持っていく必要がある。記載順が変わるだけで容易に壊れてしまう恐れがあるので、今後のメンテナンス性にも注意する必要がある。
class ApplicationController < ActionController::Base
rescue_from Exception, with :other_error_handler # 広い範囲を補足したい場合は「一番上」に
rescue_from User::NotAuthorized, with: :deny_access
rescue_from ActiveRecord::RecordInvalid, with: :show_record_errors
# ...
end
ラップされた例外
また発生した例外がどうラップされているかによって挙動が意図せず変わってしまう問題もある。ラップされている例外が cause で取り出されていた挙動が、Exception のハンドラを登録することで必ず起こらなくなる。
前述の例のように Mysql2::Error::ConnectionError
を補足しようとしている場合、データベースとの接続エラーが発生すると :show_internal_server_error
がそれを処理することになる。
class ApplicationController < ActionController::Base
rescue_from User::NotAuthorized, with: :deny_access
rescue_from ActiveRecord::RecordInvalid, with: :show_record_errors
rescue_from Mysql2::Error::ConnectionError, with: :show_internal_server_error
# ...
end
ここで次のように Exception
のハンドラも追加したとする。
class ApplicationController < ActionController::Base
rescue_from Exception, with :other_error_handler # 1 周目の検索で必ずここにマッチする
rescue_from User::NotAuthorized, with: :deny_access
rescue_from ActiveRecord::RecordInvalid, with: :show_record_errors
rescue_from Mysql2::Error::ConnectionError, with: :show_internal_server_error # cause で取り出した 2 週目の検索でここにマッチしていた
# ...
end
この状態で接続エラーが発生すると、これまでとは異なり :other_error_handler
に ActiveRecord::ConnectionNotEstablished
が渡ってくる挙動になる。
- これまでは 1 周目の rescue_handlers の検索で
ActiveRecord::ConnectionNotEstablished
にマッチするハンドラが見つからず、cause でMysql2::Error::ConnectionError
が取り出され、2 周目で:show_internal_server_error
にマッチしていた - 変更後は 1 周目で
ActiveRecord::ConnectionNotEstablished
が:other_error_handler
にマッチしそこで処理されるように変わった
注意してハンドラを登録すれば問題は避けられる。ただ投げられるすべての例外がどうラップされているのかを把握するのは現実的ではなく、思いもよらない挙動につながってしまう恐れは今後も残り続けてしまう。
まとめ
- rescure_from は以下の挙動をする
- 登録したハンドラの逆順で評価される
- 例外がどのハンドラにもマッチしなかった場合、cause で元の例外を取り出して再度検索する
- そのため Exception や StandardError を rescue_from すると意図しない挙動になってしまう恐れがある
- Exception, StandardError に対応するハンドラに意図せずに処理が寄ってしまうことがある
- 落とし穴を避けて実装することもできるが、変更に弱いコードになってしまうリスクがある
- rescue_from を書く位置や例外の内容によって、容易に意図しない挙動になってしまうリスクがある
参考
- Action Controller Overview — Ruby on Rails Guides
- ActiveSupport::Rescuable::ClassMethods
- rails/rescuable.rb at 82bab92cfe9ab62793cc82d25e6662e4359352e9 · rails/rails
- Object#is_a? (Ruby 3.1 リファレンスマニュアル)
- Exception#cause (Ruby 3.1 リファレンスマニュアル)