複数の現場で、rescue StandardErrorでメソッド全体を囲んで、ログを出してSentryに送って、そのまま握り潰すコードをよく見かけます。

動きます。ログも出ますし、Sentryにも飛びます。

ただ、この形で書く必要はほとんどないと思っています。そしてStandardErrorが悪いというより、書く前の判断が抜けていることが多い。

よく見かける形

def import
  # 処理
rescue StandardError => e
  Rails.logger.error(e)
  Sentry.capture_exception(e)
end

これを例に考えていきます。

なぜ増えるのか

必要のないrescueが、なぜこれだけ書かれるのか。理由はいくつか思い当たります。

「例外は捕まえるもの」という習慣。 Javaの検査例外のように、捕まえないと進めない言語の文化が混ざっているのかもしれません。

サンプルコードの形。 チュートリアルや記事のコードがrescue => eの形になっていることがあります。説明のために書かれたものが、そのまま持ち込まれる。

本番で落ちた経験。 一度落とすと、防御的に全部囲むようになる。気持ちは分かりますが、フレームワークの機能を理解して使う方がコードが小さくなり、可読性も上がります。

そして、コピペで増える。人間もAIも増やす。 1箇所あると、周りも同じ形になります。新しく書く人は既存コードを見て倣うので、パターンが増殖する。AIも同じで、既存コードを参照して書くので、良くないパターンをそのまま量産します。アラート対応をAIに任せると、rescueで握り潰して通知を止めようとすることもあります。エラーを消すことが目的になってしまう。

最後が一番大きい気がします。良くないパターンでも、周りに合わせて書かれてしまう。しかもAIだと、その増殖が速い。

順番に考える

rescueを書きたくなったら、この順で考えています。

  1. そもそもrescueが要るのか
  2. 要るなら、例外クラスを絞れないか
  3. 絞れないなら、スコープを狭められないか

StandardErrorを使うこと自体が悪いわけではありません。後戻りのできない例外時の処理や、gemが独自の例外を投げていて絞りようがない、という場面はあります。

問題は、広いスコープに広い指定を重ねていることです。

そもそもrescueが要るのか

冒頭の例は、例外を捕まえてログを出し、Sentryに送っています。そして再送出していないので、そのまま握り潰されます。

このうちログとSentryは、書かなくても出ます。

Railsは未処理の例外を500として扱い、ログに記録します。Sentryを入れていれば、そこで自動的にキャプチャされる。同じことを手で書いているだけです。

しかも手で書くと、

  • ログの形式が揃わない。必要な情報が欠落する。片方だけ直して食い違う
  • タグやユーザー情報など、自動キャプチャなら付く文脈が揃わないことがある
  • 本来の処理より例外処理の方が行数が多い、という状態になる

どう扱うかは設定側に寄せて、アプリケーションコードは処理だけ書く。 責務の分け方の問題です。

「エラーレスポンスを整えたい」と思ってrescueしていることもありますが、500の中身をフロントが見ることはまずありません。ステータスだけ見て、共通のエラー画面を出すのが普通です。

developmentだとスタックトレースが画面に出るので気になりますが、それはフレームワークがやってくれていることです。むしろrescueで隠すと、開発中に画面で確認できなくなります。

どうしてもJSONで返したいなら、config.exceptions_appでリクエストヘッダを見て出し分けられます。developmentのスタックトレースもそのままにできます。アクションごとにrescueを書く必要はありません。

残るのは「握り潰す」という部分ですが、これが一番厄介です。

save!・create!・update!とrescueをセットで書かない

コントローラでもよく見る形です。

# こうではなく
def create
  space = Space.new(space_params)
  space.save!
  render json: { space: }, status: :created
rescue ActiveRecord::RecordInvalid => e
  render json: { errors: e.record.errors }, status: :unprocessable_entity
end

# こう書く
def create
  space = Space.new(space_params)
  if space.save
    render json: { space: }, status: :created
  else
    render json: { errors: space.errors }, status: :unprocessable_entity
  end
end

save!は落ちない想定の時に使うものです。落ちうると分かっているならsaveを使って戻り値で分岐すればよく、rescueは要りません。create!やupdate!も同じです。

save!とrescueをセットで書いているのは、使い分けの問題であって、例外処理の問題ではありません。

握り潰すと、遠くで別のエラーになる

例外を捕まえて何も返さないと、呼び出し側は成功したと思って進みます。後続の処理でエラーになるかもしれませんし、落ちずに不正なデータのまま進むこともある。

落ちたとしても、本当の原因から遠い場所です。スタックトレースを見ても、そこには本当の原因が残っていません。落ちなければ、そもそも間違いに気づけません。

デバッグも面倒です。例外が発生した行では止まらず、rescueに飛びます。デバッガを仕掛けても原因の場所を直接は見られない。

最近はAIに投げれば当たりをつけてくれるので、以前ほど困らなくなりました。ただ、そもそも最初の行で落ちていれば1秒で分かる話です。

握り潰す時にRails.logger.errorだけで済ませていると、Sentryには飛びません。ログに残っていても、見に行く人がいなければ同じことで、誰も気づきません。握り潰していたせいで、長い間気づかれていなかったケースを見たことがあります。

異常なら異常として落とす。それで困らないなら、rescueは要りません。

例外クラスを絞れないか

rescueが必要だとしても、StandardErrorで受ける必要がない場合があります。Fail Fast(想定外は失敗させて気付けるようにする)にしておけば、負債に気付けますし、データ不整合にも素早く対応できます。

StandardErrorはRubyの例外階層の中で、アプリケーションが扱う例外のほぼ全部を含むクラスです。rescueにクラスを書かないと、これが指定されたことになります。

下記のように、クラスを指定すれば、何を想定しているかがコードに残ります。タイムアウトは起こりうるのでリトライする。それ以外は想定していない。

def fetch
  # 通信
rescue Net::OpenTimeout, Net::ReadTimeout
  # リトライする
end

StandardErrorだとこの情報が消えます。読む側は「何を想定したのか」を推測することになる。

そしてStandardErrorは、NoMethodErrorやArgumentErrorのような実装のバグまで拾います。nilにメソッドを呼んでしまった、引数の数を間違えた。その場で落ちれば原因が分かるものが、別の扱いになってしまう。

絞り込んだ結果、想定していなかった例外が出るようになったら、それは想定の漏れが見つかったということです。その時にクラスを足せばいい。最初から全部捕まえていると、そのシグナルが届きません。

とはいえ、gemの例外がドキュメントに書かれていない場合はあります。コードを追えば分かりますが、バージョンアップで変わることもある。その時はStandardErrorでも構いません。ただしスコープは最小にする、というのが次の話です。

スコープを狭められないか

クラスが絞れているなら、スコープは広くても構いません。前節のNet::OpenTimeout, Net::ReadTimeoutのように特定のクラスを指定していれば、メソッド全体にかけても他の例外は素通りします。

問題は、絞れなかった場合です。StandardErrorで受けるなら、メソッド全体にかけるべきではありません。

# こうではなく
def import
  data = convert!(input)
  Record.create!(data)
  notify
rescue StandardError
  @errors << '変換に失敗しました'
end

# こう書く
def import
  data = begin
    convert!(input)
  rescue StandardError
    @errors << '変換に失敗しました'
    return
  end

  Record.create!(data)
  notify
end

やっていることは同じで、囲む範囲だけが違います。

絞れないのがconvert!だけなら、そこだけ囲みます。メソッド全体にかけると、Record.create!のバリデーションエラーやnotifyの失敗まで「変換に失敗しました」になります。まったく別の問題なのに、変換の失敗として扱われる。

スコープが小さければ、StandardErrorでも害は小さい。1行だけを囲んでいるなら、そこで起きうる例外はそもそも限られています。

そもそも例外にしない

なおconvert!が自前の実装なら、例外にしないという選択があります。

converter = Converter.new(input)
unless converter.convert
  @errors += converter.errors
  return
end

saveと同じで、失敗を戻り値で返して理由をerrorsに入れればいい。raiseは読む側の負担が大きいので、避けられるなら避けます。

スコープを狭める話が必要になるのは、自分で制御できない処理を呼んでいる時です。

トランザクションの中でrescueしない

スコープを狭めようとして、トランザクションの中にrescueを置くと壊れます。

# 中でrescue:整合性が壊れる
ApplicationRecord.transaction do
  order.update!(status: :ordered)
  begin
    stock.decrement!(:quantity)
  rescue StandardError => e
    Rails.logger.error(e)
  end
end

在庫の更新が失敗しても、例外がトランザクションの外に出ないので、コミットされます。受注済みなのに在庫が減っていない状態が残る。

# 外でrescue:ロールバックはされるが、握り潰す
begin
  ApplicationRecord.transaction do
    order.update!(status: :ordered)
    stock.decrement!(:quantity)
  end
rescue StandardError => e
  Rails.logger.error(e)
end

こちらは両方ともロールバックされるので、整合性は保たれます。ただ、呼び出し側は成功したと思って進みます。

# rescueなし:ロールバックされて、例外も上に伝わる
ApplicationRecord.transaction do
  order.update!(status: :ordered)
  stock.decrement!(:quantity)
end

これが一番いい形です。ロールバックされて、例外はそのまま上に伝わり、ログにもSentryにも出ます。

トランザクションは例外でロールバックする仕組みなので、rescueを足すほど壊しやすくなります。

なお、トランザクションを止めたい時にraise ActiveRecord::Rollbackという書き方もあります。Railsが用意している専用の例外で、ロールバックはしますが、transactionブロックがこの例外を飲み込むので外には伝わりません。呼び出し側は成功したと思って進みます。これも静かな握り潰しです。

トランザクションと握り潰しを離さない

トランザクションをコントローラに、握り潰しをサービスに書いていると、離れたファイルに分かれて気づけません。コントローラを読んだ人は「失敗したらロールバックされる」と思って読みます。

サービスが自分でトランザクションを持っていても構いません。Railsではネストしたトランザクションは新しく作られず、外側に合流します。

ApplicationRecord.transaction do
  order.accept!               # 中でtransactionを張っている
  stock.decrement!(:quantity) # ここで例外
end

decrement!で例外が出れば、accept!の更新も含めて全部ロールバックされます。内側のトランザクションは独立していないので、途中まで確定していることはありません。

逆に、上位が包んでいなければ、サービスごとに個別にコミットされます。

def create
  order.accept!               # ここでコミット確定
  stock.decrement!(:quantity) # ここで例外 → 戻らない
end

複数にまたがる整合性は、呼ぶ側が包まないと守れません。

そして、守るべきはトランザクションの有無ではなく、例外を外まで届けることです。内側では次の2つをやらない。

  • rescueで握り潰す — 外側から見えないまま、全体がコミットされる
  • raise ActiveRecord::Rollback — 内側のブロックが飲み込むので、何もロールバックされず外側はコミットされる

握り潰していい場合

ここまで「握り潰すな」と書いてきましたが、握り潰すのが正しい場合もあります。

失敗そのものが結果の一部になるケースです。

begin
  # Slackに通知
  send_history.status = :success
rescue StandardError => e
  send_history.status = :failure
  send_history.error_message = e.message
end
send_history.completed_at = Time.current
send_history.save!

Slackへの通知が失敗しても、送信履歴は残さなければなりません。ここで落とすと、試みたこと自体が記録されない。

握り潰しているように見えますが、statusとerror_messageに残しているので、失敗は消えていません。

ただし条件があります。

  • 失敗しても続きの処理が必要なこと — 落とすと本来残すべきものが残らない
  • スコープが狭いこと — 失敗しうる部分だけを囲む
  • 記録が残ること — 状態として残すか、Sentryに飛ばす

この3つが揃っていれば、StandardErrorでも構いません。

なおSentryに飛ばすかは、性質で決まります。この例だとwebhook URLの設定ミスのようなユーザー起因の失敗も混ざるので、全部上げると騒がしい。履歴に残して画面で見せる方が合っています。

メールは主処理かどうかで分かれる

同じ形で悩みやすいのがメールです。

def register
  user.save!
  UserMailer.welcome(user).deliver_now
end

送信に失敗すると、登録まで失敗扱いになります。これを避けようとしてrescueを書きたくなる。

ただ、その前に非同期にできないかを考えます。

UserMailer.welcome(user).deliver_later

deliver_laterならキューに入るだけなので、送信の失敗は主処理と切り離されます。失敗すればジョブがリトライされ、最終的に落ちればSentryに飛ぶ。rescueが要らなくなります。

送信数の制限やレピュテーションを考えて、あえて同期で送ることもあります。ただそれは送るタイミングの話で、失敗の扱いとは別です。

同期で送るなら、メールが主処理かどうかで決まります。

  • 通知の送信そのものが目的 — 握り潰さない。送ったつもりで送れていない状態になる
  • 登録処理のついで — 上のSlack通知と同じ形にする

握り潰すとリトライされない

Sidekiqが失敗したジョブをリトライしてくれるのは知られていますが、rescueで握り潰すとリトライされません。

sidekiq_options retry: 10

def perform
  # 処理
rescue StandardError => e
  Rails.logger.error(e)
  Sentry.capture_exception(e)
end

retry: 10と書いてあるのに、一度も動きません。例外がSidekiqまで届かないので、成功扱いで終わります。

設定はリトライする前提、実装は握り潰す。両方を読まないと気づけません。

リトライさせたくない場合もあります。不整合が残る可能性がある時や、リトライしても結果が変わらない時。その場合はsidekiq_optionsで明示します。再実行する可能性があるならretry: 0(デッドキューに残る)、再実行させたくないならretry: false。握り潰すのとは違います。

ActiveJobならretry_onやdiscard_onが同じ役割です。

まとめ

  • 書く前に、そもそも要るのかを考える。ログもSentryもレスポンスも、書かなくても出る
  • 握り潰すと、遠くで別のエラーになる。ログだけに出すと誰も気づかない
  • 絞れるなら例外クラスを指定する。何を想定しているかがコードに残る
  • 絞れないなら、スコープを最小にする。StandardErrorが悪いのではなく、広いスコープに広い指定を重ねるのが問題
  • トランザクションやリトライの中で握り潰さない。仕組みが効かなくなる
  • 失敗が結果の一部になるなら握り潰していい。ただし記録は残す

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です