Railsアプリの例外ハンドリングとエラーページの表示についてまとめてみた

Railsアプリケーションの例外ハンドリングとエラーページの表示方法については色々な方法があると思いますが、そのなかでもよく使われる方法についてまとめてみました。

# 前提

- Rails 4.2.6

# Rails 標準の仕組みで静的ページを表示する

Rails は標準では Rack Middleware の`ActionDispatch::ShowExceptions`の仕組みで例外を捕捉して、エラーページを表示しています。その際、捕捉した例外に対してどんなエラーページを表示するのかを`ActionDispatch::PublicExceptions`が処理しています。
例えば`ActionController::RoutingError`が発生した場合、対応する404のHTMLファイル`public/404.html`が表示される、という感じです。
なお、`rails generate`したときに用意されるHTMLファイルは`404.html`, `422.html`, `500.html`です。

この捕捉する例外とステータスコードとの割り当ては[`ActionDispatch::ExceptionWrapper.rescue_responses`のHashで定義されます](https://github.com/rails/rails/blob/4-2-stable/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb#L7-L20)。ここに定義されていない例外に関しては500として扱われます（Hashのデフォルト指定`Hash.new(:internal_server_error)`より）
ここで使用されている Symbol は[`Rack::Utils::SYMBOL_TO_STATUS_CODE`にある](https://github.com/rack/rack/blob/1-6-stable/lib/rack/utils.rb#L661)ので適宜参照してください。
また、エラーコードに対応するテンプレートがない場合は404として扱われ、画面には何も表示されません。

開発環境での動作確認は`config.consider_all_request_local = false`にしてお目当ての例外を発生させればOKです。

ところで、新たに例外を捕捉してエラーページを表示したいときはどうすればよいでしょうか？
例えば`Banken::NotAuthorizedError`を`403 Forbidden`としてエラーページを表示したいとします。
これに任意の定義を追加するには、`config.action_dispatch.rescue_responses`に追加したい例外とステータスコードを割り当て、`public/403.html`を用意しておけば良いです。

```ruby
# config/application.rb
Rails.application.configure do
  config.action_dispatch.rescue_responses = {
    "Banken::NotAuthorizedError" => :forbidden
  }
end
```

なお、`format=json`のリクエストに関してはきちんとJSON形式で返るようになっていますが、内容は自由に設定できないようです。

この方法は一番面倒がなくて不具合を仕込む可能性も少なく、安全だと思います。
デメリットとしては静的なファイルである必要があるため、既存のテンプレートやcssを使えないですし、状況に応じた出し分けが難しいです。
ただ、エラーページはシンプルに保てるならそれが一番良いとも思います。普段表示されないページなのでレイアウト崩れや不具合に気付く機会が少ないですし、最低限正常に表示されないとユーザーに状態を伝えるという目的を果たせないです。

それでも、もっと色々なことをやりたいという要求はあるでしょう。
平常時と同じヘッダーを404ページにも出して、ユーザーが迷ってしまうことを減らし離脱を抑えたいとか、ログイン状態に関連する不具合で500になっているとき、ログイン中のユーザーにはログアウトへの動線を用意して一次解決を促したいとか、そういう時は動的なエラーページの制御が必要になります。

# Controllerでの`rescue_from`による例外捕捉

動的なエラーページを表示する方法としては、おそらく`ApplicationController`で下記のようにするのが最もポピュラーで手軽な方法だと思います。

```ruby
class ApplicationController < ActionController::Base
  unless Rails.env.development?
    rescue_from StandardError, with: :render_500
    rescue_from ActiveRecord::RecordNotFound, with: :render_404
    ...
  end

  def render_404
    logger.error "404だよ: #{exception.message}" if exception

    render template: 'errors/error_404', status: 404, layout: 'application'
  end
  
  def render_500
    logger.error "500だよ: #{exception.message}" if exception

    render template: 'errors/error_500', status: 500, layout: 'application'
  end
end
```

ただ、この方法には多少問題があります。

1. Rack Middleware で一部の例外を拾えなくなり、それらがやっていたことを自前でやらないといけない。NewRelic, Airbrake などの Rack Middleware に差し込むタイプのgemも（標準で）使えなくなる
1. `ApplicationController`の呼び出しより後の例外しか捕捉できない。例えばRoutingで発生した例外は対象外となる
1. `ShowExceptions`での例外捕捉と`ApplicationController`による例外捕捉が混在し、いつどのエラーページが出るか把握しづらい

経験上、2のように`ApplicationController`外の例外までも捕捉しなければならない状況は`ActionController::RoutingError`位で、他はあまり遭遇したことがありません。`RoutingError`もRouterの記述で回避できることが多いです。それが良いかどうかは議論の余地があると思いますが、以上のようなことが気になるときは、他の手段を取りたいです。

# `exceptions_app`による例外捕捉

Rack Middleware の`ActionDispatch::ShowExceptions`は、`config.exceptions_app`に指定したオブジェクトに対して`#call`を呼び出して例外を捕捉する仕組みです。
デフォルトではこれに`ActionDispatch::PublicExceptions`が設定されていて、これが一番最初に紹介した方法になります。

ここで`config.exceptions_app`に任意のオブジェクトを設定することで、動作をカスタマイズすることができます。（このオブジェクトは`#call`に応答できる必要があります）

## Controllerオブジェクトを利用する場合

`ActionController::Base`の資産を利用して、`exceptions_app`で動的renderしようという作戦です。

```ruby
# app/controllers/errors_controller.rb
class ErrorsController < ActionController::Base
  rescue_from StandardError, with: :internal_server_error
  rescue_from ActiveRecord::RecordNotFound, with: :not_found

  def show
    raise env['action_dispatch.exception']
  end

  def not_found
    render 'not_found', status: 404, layout: 'error'
  end

  def internal_server_error
    render 'internal_server_error', status: 500, layout: 'error'
  end
end

# config/initializers/exceptions_app.rb
Rails.application.configure do
  config.exceptions_app = ErrorsController.action(:show)
end
```

`env['action_dispatch.exception']`は発生した例外で、`ShowExceptions`が捕捉し、`exceptions_app`が呼ばれる際にセットされます。
これを再度`raise`して`rescue_from`で拾い、対応するテンプレートをrenderしています。（一例であって、このような方法でなくても対応するテンプレートがrenderできれば良いですね）
ちなみに`exceptions_app`内で発生した例外が捕捉されなかった場合は`ActionDispatch::ShowExceptions.FAILSAFE_RESPONSE`が返ることになります（500）。

envには下記の値がセットされているので必要に応じて利用できます。

```ruby
env["action_dispatch.exception"] = wrapper.exception    # 捕捉した例外
env["action_dispatch.original_path"] = env["PATH_INFO"] # もともとのリクエストのパス
env["PATH_INFO"] = "/#{status}"                         # 例外に対応するステータスコードのパス（`/404`など）
```

また、上記`ErrorsController`は`ActionController::Base`を継承しています。
`ApplicationController`を継承していない意図は、エラー表示という目的を達成するための最小限の機能を持たせたいからですが、実際は一部の`ApplicaitonController`の機能が必要になってくることもあります。そのときは適宜モジュール化してincludeする方向で対処するのが良いと思います。
さらには例外発生元で取得したオブジェクトを引き継ぎたいということもあるかもしれません。その場合はenv経由で参照を受け渡す形になるのかなと思います。

ところで上記のようにすると、例外とエラーページの対応を自前で行わなければいけないです。そういうのが面倒だったり、見落としがありそうで怖かったりする場合は、gemを利用するのが一番早いと思います。
gemを使う場合、[Rambulance](https://github.com/yuki24/rambulance)が小さくかつ必要な機能を網羅していて、とても良さそうです。設定方法はREADME、もしくは他に詳しく説明している記事を参照していただければと思います。
便利機能として、開発環境での動作確認は`/rambulance/ERROR_STAUTS_NAME`にアクセスすることでも行えます。Router経由になるので少し動作は異なりますが、表示の確認で重宝します。

## Routerを利用する場合

`exceptions_app`を利用したもうひとつの方法として、下記のようにすることができます。

```ruby
# config/initializers/exceptions_app.rb
Rails.application.configure do
  config.exceptions_app = self.routes
end

# config/routes.rb
Rails.application.routes.draw do
  get '404', to: 'errors#not_found'
end

# app/controllers/errors_controller.rb
class ErrorsController < ActionController::Base
  def not_found
    render status: 404
  end
end
```

`ActionDispatch::Routing::RouteSet`は`#call(env)`に応答し、`env['PATH_INFO']`をもとにRoutesをたどります。そしてその定義に沿って`ErrorsController`のアクションを呼び出すという流れです。
`env['PATH_INFO']`には先に説明したとおり、`/404`のような例外に対応するステータスコードのパスが設定されているので、これとマッチするRoutesを用意すれば良いです。
注意点は、ステータスコードにマッチするRoutesがない場合の動作です。Routingは`ActionDispatch::Journey::Router#serve`により処理されるのですが、どのRoutesにもマッチしなかった場合、`[404, {'X-Cascade' => 'pass'}, ['Not Found']]`が返ります。
`'X-Cascade' => 'pass'`だと、`ActionDispatch::ShowExceptions`ではそのレスポンスを破棄し、`env['PATH_INFO']`の元になっているレスポンスコードで空ページを出します。例えば、`/500`を出そうとしたのにRoutingでマッチしなかった場合、ステータスコード500の空ページが出る、ということです。
なお、通常のリクエストに対するRoutingでは、`'X-Cascade' => 'pass'`だと`ActionDipatch::DebugExceptions`が`RoutingError`をraiseするようです。今回はその外側の出来事なので上記のような動作になります。

メリットとしては既存のControllerとroutesの仕組みに相乗りできるので楽できるというところでしょうか。
しかし少し遠回りな分ハマりどころがありそうなので、この方法を選択する機会はControllerオブジェクトを利用する方法よりも少なそうです。

# むすび

例外を捕捉してエラーページを表示する方法として、標準の静的ページを表示する方法と、Controllerで`rescue_from`する方法、そして`exceptions_app`をカスタマイズする方法を紹介しました。

また、`execptions_app`を利用したgemとして[Rambulance](https://github.com/yuki24/rambulance)を紹介しました。
実はもうひとつ[ExceptionHandler](https://github.com/richpeck/exception_handler)という重厚なgemも見つけたのですが、こちらは試していません。もし試したことのある方がいましたら使用感等教えていただければ幸いです。

なお、`exceptions_app`を利用する方法は`ActionDispatch::ShowExceptions`より外側の例外は捕捉することはできないので、それが問題になる場合は別の方法を検討しなければならないです。たぶん新たな Rack Middleware を必要な位置に差し込む感じになると思います。

今回色々調べてみましたが、常にこれが正解という方法はないなーと感じたのでした。
ただ、本当に動的に表示する必要があるのかどうかはしっかりと考えたほうが良さそうです。そのうえでどの方法を使うのが理にかなうのかを検討するのが良いと思います。
なのでサービスの性質やアプリケーションの要件に応じて、必要十分な方法で対応できるようになっていることが大事なのだと思います。

# 参考

- [Rails の rescue_from で拾えない例外を exceptions_app で処理する - Qiita](http://qiita.com/ma2ge/items/938d9f8f4839eb336318)
- [Dynamic Error Pages In Rails | We Are Stac](https://wearestac.com/blog/dynamic-error-pages-in-rails)