はじめに
Railsには便利な機能がたくさんありますが、便利すぎて逆に困ることもあります。今回はその一つ、ActiveRecord系例外の自動ステータスコード変換について書いてみます。
Railsの挙動
Railsでは、特定の例外が発生すると自動的にHTTPステータスコードが決まります。
-
ActiveRecord::RecordNotFound→ 404 Not Found -
ActiveRecord::RecordInvalid→ 422 Unprocessable Entity -
ActiveRecord::StaleObjectError→ 409 Conflict
User.find(params[:id])で見つからなければ自動で404。便利ですよね。
何が問題なのか
でも、ちょっと待ってください。
RecordNotFoundが発生するのは、本当に「リソースが存在しない」ときだけでしょうか?
例1: マスタデータの参照
class OrdersController < ApplicationController
def show
@order = Order.find(params[:id])
@prefecture = Prefecture.find(@order.prefecture_id) # マスタ整理で消えてたら?
end
end
こんな極端な例は普通ないと思いますが、似たようなケースに遭遇したことありませんか?
例2: タスクの通知設定
class Tasks::NotificationsController < ApplicationController
def update
@task = Task.find(params[:id])
@notification_setting = @task.notification_settings.find_by!(user: Current.user)
@notification_setting.update!(enabled: params[:enabled])
end
end
通知設定画面に来ている時点で、設定レコードは存在するはず。でも過去のバグやバッチ処理の不具合で消えていたら...?
例3: タスク複製時のバリデーションエラー
class Tasks::DuplicatesController < ApplicationController
def create
@original = Task.find(params[:task_id])
@new_task = Task.new(
title: @original.title,
description: @original.description,
project_id: @original.project_id
)
@new_task.save!
end
end
元タスクの project_id をそのままコピーしている。でも、titleのバリデーションが元タスクの作成時より厳しくなっていたら...?
これらは本来500(サーバーエラー)であるべきなのに、404や422が返ってしまいます。
何がまずいかというと:
- エラー監視で見落とされる(かもしれない) 404は正常系として扱われがち
- 原因調査が遅れる 「ユーザーが存在しないIDを叩いただけでしょ」と思われる
- データ不整合が放置される
仕組みを理解する
実はこの挙動、ActionDispatch::ExceptionWrapperというミドルウェアが担っています。
# Rails内部の定義(一部抜粋)
# https://github.com/rails/rails/blob/main/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb
@@rescue_responses = {
"ActiveRecord::RecordNotFound" => :not_found,
"ActiveRecord::RecordInvalid" => :unprocessable_entity,
"ActiveRecord::StaleObjectError" => :conflict,
# ... 他にも色々
}
そして嬉しいことに(?)、この設定は上書き可能です。
カスタマイズしてみる
そこで、ActiveRecord系の例外は自動変換しない設定にしましょう。
# config/initializers/exception_handling.rb
ActionDispatch::ExceptionWrapper.rescue_responses =
Hash.new(:internal_server_error).merge!(
{
"ActionController::RoutingError" => :not_found,
"ActionDispatch::Http::MimeNegotiation::InvalidType" => :not_acceptable,
# ActiveRecord系は意図的に除外
# "ActiveRecord::RecordNotFound" => :not_found,
# "ActiveRecord::RecordInvalid" => :unprocessable_entity,
}
)
ポイント:
-
Hash.new(:internal_server_error)で、未定義の例外は全て500に -
RoutingErrorなど、本当に「リソースがない」ケースだけ404に - ActiveRecord系は各コントローラで明示的にハンドリング
コントローラでの明示的なハンドリング
class UsersController < ApplicationController
def show
@user = User.find(params[:id])
rescue ActiveRecord::RecordNotFound
# ここは本当に「ユーザーが存在しない」ケース
render_not_found
end
end
「面倒じゃない?」と思うかもしれますが、むしろ例外が発生しうる箇所を意識できるというメリットがあります。
ハンドリングを忘れた? 大丈夫、500エラーとしてエラー監視に引っかかります。気づけます。
まとめ
| 方針 | メリット | デメリット |
|---|---|---|
| Rails標準 | 楽、コードが少ない | データ不整合、不具合を見逃すリスク |
| 明示的ハンドリング | 異常系を確実に検知 | 面倒, Rails Wayから外れる |
Railsの「便利なデフォルト」は多くのケースで正しく動きます。でも、「便利」と「安全」はトレードオフになることもあるということは覚えておいて損はないかなと思います。
特にデータ整合性が重要なアプリケーションでは、今回のような設定を検討してみてはいかがでしょうか。