Railsのコントローラでの不可解(に見える)なエラーハンドリングについて
やっぱ家でもなるわ。適当なcontrollerに
— snocchy (@snocchy) 2018年4月19日
def index
raise ActiveRecord::RecordNotFound # (1)
rescue
raise 're-raise' # (2)
end
とか書いてブラウザから開くと何故か(1)で死んだことになってる。コンソールから叩くと(2)になる。raiseするのをActiveRecordErrorにすればどちらも(2)
Twitterでこのようなやりとりを見かけて、気になって眠れなかったので調べてみた。 長ったらしく説明しているので結論だけ知りたい方は下までスクロールしてください。
問題
手元ですぐ動かせる Rails5.1.1 / Ruby 2.5.0で試した。
# controller class WelcomeController < ApplicationController def index raise ActiveRecord::RecordNotFound rescue => e raise 're raise' end end # config/route.rp Rails.application.routes.draw do root to: 'welcome#index' end
この状態でWEBにアクセスするとこうなる!!
!? raise re raise
にならん・・・まじだ
rescueされてないようにみえる。
ちなみにこれをコンソールでやるとこうなる
コンソールだとちゃんと “re raise” が例外になる?!
次にコントローラの例外をRecordNotFoundでなくしてみる
# controller class WelcomeController < ApplicationController def index raise ActiveRecordError rescue => e raise 're raise' end end
するとちゃんと re raise
の方が例外として上がってくる…不可解
種明かし
rescueされてないよう見えるが、本当にされてないのだろうか。
binding.pry
を仕掛けてみよう
class WelcomeController < ApplicationController def index raise ActiveRecord::RecordNotFound rescue => e binding.pry raise 're raise' end end
あれ・・・ちゃんとrescueされてますね・・・ ということは、画面上の表示がおかしいのだろうか
lib/action_dispatch/middleware/debug_exceptions.rb @ line 68 ActionDispatch::DebugExceptions#call: 57: def call(env) 58: request = ActionDispatch::Request.new env 59: _, headers, body = response = @app.call(env) 60: 61: if headers["X-Cascade"] == "pass" 62: body.close if body.respond_to?(:close) 63: raise ActionController::RoutingError, "No route matches [#{env['REQUEST_METHOD']}] #{env['PATH_INFO'].inspect}" 64: end 65: 66: response 67: rescue Exception => exception => 68: raise exception unless request.show_exceptions? 69: render_exception(request, exception) 70: end [1] pry(#<ActionDispatch::DebugExceptions>)> exception => #<RuntimeError: re raise>
render_exception
というエラーページを生成しているところまでちゃんと re raise
のエラーが渡ってきていますね。
では render_exception の中身を見ていきましょう
# actionpack-5.1.1/lib/action_dispatch/middleware/debug_exceptions.rb def render_exception(request, exception) backtrace_cleaner = request.get_header("action_dispatch.backtrace_cleaner") wrapper = ExceptionWrapper.new(backtrace_cleaner, exception) log_error(request, wrapper) if request.get_header("action_dispatch.show_detailed_exceptions") content_type = request.formats.first if api_request?(content_type) render_for_api_request(content_type, wrapper) else render_for_browser_request(request, wrapper) end else raise exception end end
ExceptionWrapper.new
が怪しそう。さらにこのコードを追ってみます。
# actionpack-5.1.1/lib/action_dispatch/middleware/exception_wrapper.rb module ActionDispatch class ExceptionWrapper # 〜〜〜省略〜〜〜〜 @@rescue_responses.merge!( "ActionController::RoutingError" => :not_found, "AbstractController::ActionNotFound" => :not_found, "ActionController::MethodNotAllowed" => :method_not_allowed, "ActionController::UnknownHttpMethod" => :method_not_allowed, "ActionController::NotImplemented" => :not_implemented, "ActionController::UnknownFormat" => :not_acceptable, "ActionController::InvalidAuthenticityToken" => :unprocessable_entity, "ActionController::InvalidCrossOriginRequest" => :unprocessable_entity, "ActionDispatch::Http::Parameters::ParseError" => :bad_request, "ActionController::BadRequest" => :bad_request, "ActionController::ParameterMissing" => :bad_request, "Rack::QueryParser::ParameterTypeError" => :bad_request, "Rack::QueryParser::InvalidParameterError" => :bad_request ) # 〜〜〜省略〜〜〜〜 def initialize(backtrace_cleaner, exception) @backtrace_cleaner = backtrace_cleaner @exception = original_exception(exception) expand_backtrace if exception.is_a?(SyntaxError) || exception.cause.is_a?(SyntaxError) end # 〜〜〜省略〜〜〜〜 private def original_exception(exception) if @@rescue_responses.has_key?(exception.cause.class.name) exception.cause else exception end end # 〜〜〜省略〜〜〜〜 end
読んでいくと以下のような動きになっていることがわかります。
initialize
でoriginal_exception
というのを呼ぶ、original_exception
ではexception.cause
で元々発生していた例外を見に行く- それが
rescue_responses
に一致する場合には、それをExceptionWrapper
のexception
にセットする
でも、ここには ActiveRecord::NotFound
に対応するものはありません。
実はここではなくて、ActiveRecod
の方で拡張が行われているようです。(コードちゃんとおったわけではないけど多分これ)
# activerecord-5.1.1/lib/active_record/railtie.rb module ActiveRecord # = Active Record Railtie class Railtie < Rails::Railtie # :nodoc: # 〜〜〜省略〜〜〜〜 config.action_dispatch.rescue_responses.merge!( "ActiveRecord::RecordNotFound" => :not_found, "ActiveRecord::StaleObjectError" => :conflict, "ActiveRecord::RecordInvalid" => :unprocessable_entity, "ActiveRecord::RecordNotSaved" => :unprocessable_entity ) # 〜〜〜省略〜〜〜〜 end
ここまで来てやっとわかりました。例外のその前に発生していた例外が、ActiveRecord::RecordNotFound
の場合には、画面上に発生した例外そのもの(今回でいうと re raise
)ではなく、元々の例外であるActiveRecord::RecordNotFound
が表示される。という挙動になっているようです。
結論
実際にTweetされてる方の環境を確認したわけではないので完全なる推測ですが、以下のような状況なのではないでしょうか
- エラーハンドリングはきちんと行われている
- しかし特定の例外の場合には、rescue節の例外ではなく、その前に発生していた元々の例外の情報が画面に表示される
- 上記の挙動により
ActiveRecord::RecordNotFound
がrescue
されていないような不可解な挙動に見えた
ちなみにこの挙動はRails3.2.0RCからあるもののようです。
追記
もともとの実装はこっちか https://t.co/DGtctOgyYz
— すーさん二号 (@suusan2go) 2018年4月20日
僕も気になったので調べてみたんですが元のチケットとコミットはこれですねhttps://t.co/yTBxoHM9f6https://t.co/lM1Y0LdoaA
— わかば (@wakaba260yen) 2018年4月20日
何らかの例外をラップした例外を返しても、大本のエラーがrescue_responseの対象として登録されてたらrescue_responseを適用するようにするようにしているみたいです