Web API 混在の Rails アプリケーションで500エラーを適切に処理したい

## はじめに

Rails で、Web API でJSONを返すものと、HTMLのページを返すものを混在させたアプリケーションを `rails new` で作成していました。

routing で、Web API は、 `/api` 以下にマウントしていました。

`/api/xxx` で Web API が呼ばれて想定外のエラーが発生した時、JSON でレスポンスを返すようにしたいと考えたのですが、意外に手間取ったので自分用にメモしておきます。

なお、動作確認は、Rails7.1 で実施しています。

## 500エラーを発生させる
意図的に 500 エラーを発生させるために、Controller で以下のようなコードを書きます。

```ruby
class Api::UsersController < ApplicationController
  def index
    raise StandardError, 'error'
  end
end
```

## 何もしない場合
何もしない場合、Rails は、`/500.html` を返します。JSON ではなくて、HTML が返ってきます。

ただし、リクエストするときに、HEADER で、`Accept: application/json` を指定している場合は、JSON が返ってきます。

```bash
curl -H "Accept: application/json" http://localhost:3000/api/users # => JSON
curl http://localhost:3000/api/users # => HTML
```

今回は、`/api` 以下のリクエストに対しては JSON を返すようにしたいです。

## rescue_from StandardError は推奨されない
そこで、`/api` の場合だけ、 `rescue_from` を使って、`StandardError` をキャッチして JSON を返すようにしようと考えましたが、`rescue_from` で、 `StandardError` をキャッチするのは推奨されないということが Rails Guide に記載されていました([Rails Guide](https://guides.rubyonrails.org/action_controller_overview.html#rescue-from))。

## config.exceptions_app を使う
別の方法を色々探していたのですが、`config.exceptions_app` を使う方法がいくつかみつかりました。
- [Railsアプリの例外ハンドリングとエラーページの表示についてまとめてみた](https://qiita.com/upinetree/items/273ae574f1c021d24c37#exceptions_app%E3%81%AB%E3%82%88%E3%82%8B%E4%BE%8B%E5%A4%96%E6%8D%95%E6%8D%89)
- [カスタム例外を小さな exceptions_app でハンドリングする](https://qiita.com/QUANON/items/e701a0f1e4269771ee24)

ちなみに、調べている途中で見つかった[Rambulance](https://github.com/yuki24/rambulance) gem も `config.exceptions_app` を使っているようでした。

## config.exceptions_app を試してみる

`config/initializers/exceptions_app.rb` を作成して以下のように記述します。

```ruby:config/initializers/exceptions_app.rb
Rails.application.configure do
  config.exceptions_app = -> (env){ ErrorController.action(:show).call(env) }
end
```

`app/controllers/error_controller.rb` を作成して以下のように記述します。

```ruby:app/controllers/error_controller.rb
class ErrorController < ApplicationController
  def show
    exception = request.env["action_dispatch.exception"]
    original_path = request.env["action_dispatch.original_path"]
    Rails.logger.error "Rendering 500 with exception in #{original_path}: #{exception.class.name}, message: (#{exception.message}), backtrace: #{exception.backtrace}"

    # /api 以下のリクエストの場合は、JSON を返す
    if original_path.start_with?("/api/")
      render_error_json(exception) and return
    end

    respond_to do |format|
      format.json { render_error_json(exception) }
      format.html { render file: Rails.root.join("public", "500.html"), status: :internal_server_error, layout: false }
    end
  end

  private

  def render_error_json(exception)
    render json: { result: "ng", message: exception.message }, status: :internal_server_error
  end
end
```

## config.consider_all_requests_local を false にする

Development 環境では、`config.consider_all_requests_local` が `true` になっていて、エラーが発生したときに、ErrorController が呼ばれないので、`config/environments/development.rb` に以下のように記述します。(これに気づくのに時間がかかりました。)

```ruby:config/environments/development.rb
  config.consider_all_requests_local = false
```

## 試してみる
`/api` 以下のリクエストに対して、500エラーが発生した場合、JSON が返ってくることを確認します。
```bash
curl http://localhost:3000/api/users
{"result":"ng","message":"error"}
```

## `--api` だとどうなるか？

ちなみに `rails new --api` でアプリケーションを作成した場合だと500エラーでもJSON が返ってきます。

## まとめ
今回は、`config.exceptions_app` を使って、`/api` 以下のリクエストに対して、500エラーが発生した場合に JSON を返すようにしました。
他にもっといい方法があれば教えていただきたいです。

## 参考
- [Railsアプリの例外ハンドリングとエラーページの表示についてまとめてみた](https://qiita.com/upinetree/items/273ae574f1c021d24c37)
- [カスタム例外を小さな exceptions_app でハンドリングする](https://qiita.com/QUANON/items/e701a0f1e4269771ee24)
- [Rails の exceptions_app によるエラーページの表示をテストする](https://qiita.com/upinetree/items/5410f130c91909db347c)
- [Rambulance](https://github.com/yuki24/rambulance)
- [Rails Guide rescue-from](https://guides.rubyonrails.org/action_controller_overview.html#rescue-from)
- [Rails Guide exceptions_app](https://guides.rubyonrails.org/configuring.html#config-exceptions-app)
- [Rails Guide config.consider_all_requests_local](https://guides.rubyonrails.org/configuring.html#config-consider-all-requests-local)