はじめに
業務システムでは、画面を止めないためにエラーを吸収したくなる場面があります。catch して空配列を返す。例外をログだけに出して処理を続ける。バッチの一部失敗を成功として終える。どれも、その場では利用者への影響を小さく見せられます。
しかし、エラーを静かに処理すると、不具合は消えません。発見されないまま蓄積し、後からデータ不整合や問い合わせとして現れます。その時点では、発生時の情報も失われているため、調査に時間がかかります。
この記事では、「エラーはけたたましく叫ばせるべき」という設計思想を整理します。ここでいう「叫ぶ」は、利用者へ技術的な詳細を見せることではありません。開発・運用側が異常を見落とせず、原因を追跡できる状態にすることです。
エラーは不都合な例外ではなく、システムからの観測結果
エラーは、処理が期待どおりに進まなかったという事実です。
- 外部 API がタイムアウトした
- DB 更新がロールバックされた
- 本来あるはずのデータが取得できなかった
- 想定外の状態遷移が起きた
この事実を消しても、処理が成功したことにはなりません。成功らしい値に変換しただけです。
特に危険なのは、エラーを「利用者へ見せたくないもの」とだけ捉えることです。利用者への表示を制御する必要はありますが、内部の異常まで消す理由にはなりません。
利用者に見せる情報と、開発・運用側に残す情報は分けます。この2つを同じものとして扱うと、利用者には不親切なエラーを見せるか、運用側には何も残らないかのどちらかになりやすいです。
静かな失敗が一番調査しにくい
次のような実装を考えます。
async function getInvoices(customerId: string) {
try {
return await invoiceApi.list(customerId);
} catch {
return [];
}
}
画面には請求情報が0件と表示されます。しかし、この時点では次のどれなのか分かりません。
- 本当に請求情報が0件だった
- 検索条件が誤っていた
- 認可によってデータが除外された
- 外部 API がタイムアウトした
- 外部 API のレスポンス形式が変わった
画面も HTTP ステータスも正常で、ログも残っていなければ、調査の入口がありません。利用者からの問い合わせを受けてから、コード、DB、ネットワーク、外部サービスを広く確認することになります。
一方、失敗を分類して記録していれば、調査は「どの層で失敗したか」の確認から始められます。エラーを叫ばせる目的は、障害を大きく見せることではなく、問題の範囲を素早く狭めることです。
叫ばせる場所を分ける
すべてのエラーを同じ通知先へ大量に送ればよいわけではありません。叫ばせる先と粒度を分けます。
| 場所 | 目的 | 残す内容 |
|---|---|---|
| 利用者向け UI | 次に取る行動を伝える | 再試行、時間をおく、問い合わせる |
| API レスポンス | 呼び出し側が状態を判断できるようにする | 安定したエラーコード、再試行可否 |
| 構造化ログ | 個別事象を調査する | 操作、依存先、原因分類、リクエスト ID |
| メトリクス | 異常の増加を検知する | 失敗数、失敗率、遅延、フォールバック率 |
| アラート | 人がすぐ対応すべき事象を知らせる | 影響範囲、優先度、一次対応の手がかり |
例えば外部 API の一時的なタイムアウトなら、利用者には「現在取得できません。時間をおいて再試行してください」と表示します。運用側には、呼び出し先、タイムアウト時間、リトライ回数、影響した機能を記録します。失敗率がしきい値を超えたときだけ、当番へアラートを送ります。
この分離ができていれば、利用者を不安にさせず、運用側も異常を見落としません。
catch は解決ではない
catch は例外を受け取るための構文です。例外を解決する構文ではありません。
例外を受け取った後には、少なくとも次のいずれかの判断が必要です。
- 呼び出し側へ失敗として返す
- 安全なフォールバックを返し、フォールバックした事実を残す
- 再試行する
- 処理を中断し、補償処理やロールバックを行う
- 利用者の操作に必要な形へエラーを変換する
何もせず値だけ返す catch は、失敗を隠す実装になりやすいです。
async function getInvoices(customerId: string) {
try {
return await invoiceApi.list(customerId);
} catch (error) {
logger.error({
event: "invoice_fetch_failed",
customerId,
error,
});
throw new UpstreamServiceError("請求情報を取得できませんでした");
}
}
この例では、利用者に技術的な例外を直接返さず、内部には原因追跡に必要な情報を残します。実際には、個人情報や認証情報をログに含めないようにし、識別子の扱いを別途決める必要があります。
フォールバックしたなら、依存先の失敗も叫ばせる
画面の補助情報であれば、取得失敗時に前回値や既定値を使うことがあります。この判断自体は有効です。
フォールバックによって処理を継続できた場合、リクエスト全体を成功として扱うことはあります。ただし、依存先の取得に失敗した事実まで成功として扱うべきではありません。次のような情報を残します。
- 何の取得に失敗したか
- どの代替値を使ったか
- 利用者へどのように表示したか
- 再試行したか、何回失敗したか
- キャッシュを返したなら、その鮮度はどの程度か
フォールバック率をメトリクスにすると、「HTTP 200 は返っているが、依存先の障害で縮退が増えている」という状態を検知できます。成功率だけを見る監視では、この変化を見落としやすいです。
暗黙知にすると、エラーは再び静かになる
「このエラーは無視してよい」「この画面だけは空配列でよい」「障害時はこのダッシュボードを見る」といった判断が、経験者の頭の中にしかない状態は危険です。
新しく参加した人は、ログやレスポンスを見ても正常な状態と異常な状態を区別できません。例外を握りつぶす実装を見つけても、それが仕様なのか不具合なのか判断できず、そのまま踏襲されます。
次の情報を仕様として残すと、エラーを静かにしない運用を続けやすくなります。
- API の成功、0件、取得失敗の意味
- エラーコードと利用者向けの表示方針
- フォールバックできる機能と、できない機能
- アラートが鳴ったときの一次対応手順
- ログ、メトリクス、トレースを確認する順番
OpenAPI、エラーコード一覧、runbook、契約テストなど、実装変更と一緒に更新できる場所へ置くことが重要です。口頭で共有しただけでは、時間とともに前提が失われます。
大きく叫ぶことと、ノイズを増やすことは違う
「エラーはけたたましく叫ばせる」は、すべての例外で通知を鳴らし続けるという意味ではありません。対応不要な通知が多いと、本当に重要なアラートが埋もれます。
叫ばせるべきなのは、異常が起きたという事実です。即時に人を起こすべきかは、利用者影響、失敗率、継続時間、回復可能性で決めます。
| 状態 | 記録 | 通知の扱い |
|---|---|---|
| 一時的な失敗が1件発生 | 構造化ログとメトリクス | 原則通知しない |
| リトライ後に復旧 | ログと復旧回数のメトリクス | 傾向を監視する |
| 失敗率が上昇 | ログ、メトリクス、トレース | しきい値超過で通知する |
| 利用者操作やデータ整合性へ影響 | 必要な証跡を記録する | 即時対応として通知する |
重要なのは、何が起きても記録され、対応が必要な状態だけが適切な強さで通知されることです。通知ノイズの減らし方については、対応不要なERROR通知を減らし、重要アラートに反応できる運用を作るで整理しています。
実装レビューで確認すること
エラー処理は、正常系よりレビューで見落とされやすい部分です。次の項目を確認します。
-
catchした例外について、処理方針が決まっているか - 成功、正常な0件、取得失敗が同じ値になっていないか
- フォールバックした事実がログやメトリクスに残るか
- リクエスト ID やトレース ID で各層の記録をたどれるか
- 利用者へ技術的な内部情報を返していないか
- 個人情報や認証情報をログへ出していないか
- アラートが鳴ったとき、誰が何を確認するか決まっているか
まとめ
エラーは、利用者にそのまま見せるものではありません。しかし、内部で静かに消してよいものでもありません。
エラーをけたたましく叫ばせるとは、失敗の事実をログ、メトリクス、トレース、必要に応じたアラートへ残し、誰でも原因を追える状態にすることです。
画面を止めないことと、エラーを消すことは別です。安全に縮退しながら、異常だった事実はけたたましく残す。その方が、障害を早く発見し、早く直せるシステムになります。