こしごぇ(B)

旧:http://d.hatena.ne.jp/koshigoeb/

Railsアプリの例外処理をRackミドルウェアで

コントローラ内で発生する例外であれば、rescue_from などを利用した例外処理を行える。
一方、データベースサーバに障害が起きた場合などはコントローラの外側で例外が処理され、コントローラの rescue_from で宣言した例外処理は使われない。
今回は、アプリケーションの外側で発生する例外を処理する方法について整理してみる。

前提

例外処理には以下の Rack アプリケーションを使用する。

# app/controllers/database_failure.rb
class DatabaseFailure < ActionController::Metal
  include ActionController::Redirecting

  def self.call(env)
    action(:maintenance).call(env)
  end

  def maintenance
    redirect_to '/maintenance.html'
  end
end

# app/controllers/exceptions_app.rb
class ExceptionsApp < ActionController::Metal
  include ActionController::Rendering

  def self.call(env)
    action(:handle_exception).call(env)
  end

  def handle_exception
    render file: 'public/maintenance.html', status: :service_unavailable
  end
end

ActionDispatch::ShowExceptions

config.exceptions_app で指定した Rack アプリケーションで例外を処理させることができる。
デフォルトでは config.exceptions_app は未指定で、その場合は ActionDispatch::PublicExceptions.new(Rails.public_path) が使われる。

# config/application.rb
module Sample
  class Application < Rails::Application
    ...
    config.exceptions_app = ->(env) do
      case env["action_dispatch.exception"]
      when Mysql2::Error
        DatabaseFailure.call(env)
      else
        ExceptionsApp.call(env)
      end
    end
  end
end

※ development モードなど、config.consider_all_requests_local が真である場合は働いてくれない様なので使いどころに気をつけた方が良いかも。

ActionDispatch::Rescue

ミドルウェアスタックに ActionDispatch::Rescue を差し込む事で、任意の Rack アプリケーションに例外を処理させることが出来る。

module Sample
  class Application < Rails::Application
    ...
    config.middleware.insert_before ActiveRecord::ConnectionAdapters::ConnectionManagement, ActionDispatch::Rescue do
      rescue_from Mysql2::Error, DatabaseFailure
    end
  end
end

※ config.consider_all_requests_local に関わらず働いてくれる。

config.ru (暫定)

Rails の初期化処理で例外が発生した場合に、任意の例外処理を実行したい場合にどうしたらよいのか。
例えば、config.ru を以下の様に書いたら良いだろうか。

run ->(env) do
  begin
    require ::File.expand_path('../config/environment',  __FILE__)
    Sample::Application.call(env)
  rescue Mysql2::Error
    DatabaseFailure.call(env)
  end
end

これは上手くいかない。初期化処理が例外によって中断されるため、次のリクエストを処理する際にも初期化処理が実行されてしまい、意図しない例外が発生してしまう。
初期化処理を2回実行してしまう問題を解決する方法がまだ見つけられてない。

追試(1): DelayedJob を使うと初期化で例外終了する件

MySQL サーバ停止後に素の Rails アプリで passenger start すると、インスタンス起動時に上手く指定した例外処理を実行出来る。
そこで、gem 'delayed_job_active_record' として DelayedJob を使い始めると、インスタンス起動時に例外終了する様になる。

例外が発生するのは、バックトレースを見る限り ActiveRecord::Base.clear_cache! の部分。

    initializer "active_record.set_reloader_hooks" do |app|
      hook = lambda do
        ActiveRecord::Base.clear_reloadable_connections!
        ActiveRecord::Base.clear_cache!
      end

      if app.config.reload_classes_only_on_change
        ActiveSupport.on_load(:active_record) do
          ActionDispatch::Reloader.to_prepare(&hook)
        end
      else
        ActiveSupport.on_load(:active_record) do
          ActionDispatch::Reloader.to_cleanup(&hook)
        end
      end
    end