例外処理のコードレビューでよく指摘されるのは、構文の間違いではありません。捕まえる場所が近すぎることと、捕まえたあとに情報を捨てていることです。この 2 つを直すだけで、大半の例外処理は読めるようになります。
原則: 回復できる場所まで運ぶ
例外を捕まえてよいのは、そこで意味のある対処ができるときだけです。対処とはたとえば、リトライする、代替手段に切り替える、ユーザーに提示できる形に変換する、といった行動を指します。
ログを出して再送出するだけ、あるいはそのまま握りつぶすだけなら、捕まえる意味はありません。そこは通過させて、対処できる層まで運びます。
# 悪い例: 対処できないのに捕まえて、握りつぶしている
def load_user(user_id):
try:
return repository.find(user_id)
except DatabaseError:
return None # 呼び出し側は「いない」のか「壊れた」のか区別できない
この return None によって、DB 接続の失敗が「ユーザーが存在しない」という正常系に化けます。障害が起きているのに 404 が返り、監視にも引っかかりません。例外を握りつぶす行為は、障害を静かなバグに変換する操作だと考えると分かりやすいです。
捕まえる場所の目安は次の 3 つです。
| 層 | 捕まえるもの | 対処 |
|---|---|---|
| 外部 I/O の直近 | タイムアウト、一時的な接続断 | リトライ、フォールバック |
| ユースケース層 | 業務上の失敗 | 業務的な結果へ変換 |
| 最外周(HTTP ハンドラ、main) | 想定外のすべて | ログ・監視通知・汎用エラー応答 |
中間の層には基本的に try を書きません。書きたくなったら、それは対処ではなく不安からの捕捉である可能性が高いです。
捕まえる範囲を絞る
catch (Exception) や except Exception: のような広い捕捉は、想定外の不具合まで飲み込みます。プログラミングのミス(属性名の打ち間違い、型の取り違え)は本来クラッシュして気づくべきものです。
# 悪い例: KeyError も AttributeError も一緒に飲み込む
try:
result = parse(payload)
except Exception:
result = fallback()
# 良い例: 想定した失敗だけを捕まえる
try:
result = parse(payload)
except (ValueError, JSONDecodeError):
result = fallback()
try のブロックも短く保ちます。数十行を丸ごと囲むと、どの行が失敗しうるのか読み手に分かりません。失敗しうる 1 行を囲むのが基本形です。
広く捕まえてよいのは最外周だけです。プロセスを落とさずにエラー応答を返す責務がある場所では、catch (Throwable) 相当の網が必要になります。ただしその場合も、必ずスタックトレースごとログへ出します。
情報を足して運ぶ
例外を上位へ運ぶとき、下位の例外をそのまま投げ直すと文脈が失われます。「接続がタイムアウトしました」だけでは、何の処理の途中だったのか分かりません。
多くの言語には例外チェーン——元の例外を原因として保持したまま新しい例外を投げる仕組みがあります。
try:
response = client.fetch(url)
except TimeoutError as e:
raise FeedFetchError(f"feed の取得に失敗しました: {url}") from e
from e を書くと、元のスタックトレースが __cause__ として残ります。これを省いて raise FeedFetchError(...) とだけ書くと、根本原因が消えてデバッグが一段難しくなります。Java の initCause、.NET の innerException、Rust の source() も同じ役割です。
一方で、同じ情報を何度もログに出さないことも大事です。各層でログを出すと、1 つの失敗が何行にも分裂して原因の特定が遅くなります。ログを出すのは最終的に処理する場所、途中は文脈を足して再送出、と分けます。
リソースは構文に任せる
finally で手動解放するコードは、解放漏れと二重解放の温床です。言語が用意した構文を使います。
with open(path) as f: # Python: with
data = f.read()
await using conn = await pool.connect(); // TypeScript: using 宣言
Rust には finally がなく、値がスコープを抜けた時点で Drop が走るため、そもそも書く必要がありません。PHP や Java でも try-with-resources 相当を優先します。
もう 1 つ、finally の中で return したり例外を投げたりしないこと。本来の例外が上書きされて消えます。
業務上の失敗は例外にしなくてよい
「ユーザーが見つからない」「残高が足りない」「入力が不正」——これらは想定された分岐であって、異常事態ではありません。例外で表すと、呼び出し側が処理し忘れてもコンパイラは何も言いません。
こうした失敗は戻り値の型に載せるほうが安全です。Rust の Result、TypeScript の判別可能なユニオン型、Go の多値返却がその形です。
type WithdrawResult =
| { ok: true; balance: number }
| { ok: false; reason: "insufficient" | "frozen" };
こうしておくと、ok を確認せずに balance を読むコードは型エラーになります。失敗の処理漏れを人間のレビューではなく型検査に任せるのがねらいです。
例外が向くのは、呼び出し側が個別に対処できない失敗——インフラ障害、設定不備、プログラムのバグです。この線引きを決めておくと、「何を例外にするか」で迷わなくなります。
リトライは条件を絞る
一時的な失敗にはリトライが有効ですが、冪等でない操作(決済、送信、作成)に無条件のリトライを掛けると二重実行になります。
- リトライしてよいのは、タイムアウトや 5xx など一時的と判断できる失敗だけ
- 4xx(バリデーションエラー、認可エラー)はリトライしても結果は変わらない
- 冪等でない操作は、冪等キーを付けるか、リトライ対象から外す
- 回数の上限と指数バックオフを必ず設ける
「とりあえず 3 回リトライ」は、障害時に負荷を 3 倍にして復旧を遅らせる設定になりえます。
まとめ
- 例外を捕まえるのは、そこで意味のある対処ができるときだけ
- 握りつぶしは障害を静かなバグに変える。特に
nullや空値への変換は避ける catch (Exception)級の広い捕捉は最外周だけ。tryの範囲は失敗しうる行に絞る- 上位へ運ぶときは元の例外を原因として保持し、文脈を足す
- ログは最終処理地点で 1 回。途中の層では再送出に徹する
- リソース解放は
with/using/ RAII に任せ、finallyでreturnしない - 想定された業務上の失敗は例外ではなく戻り値の型に載せる
- リトライは一時的な失敗かつ冪等な操作に限り、上限とバックオフを付ける