本番稼働しているPythonコードベースで、裸のexcept:(例外クラスを指定しない)やexcept Exception:(広すぎる捕捉)が散在していました。全部一気に書き換えるとリグレッションのリスクが高すぎます。
そこで、正規表現で全件スキャン → スコープ別に分類 → 優先度順に段階移行というワークフローで整理した記録です。
問題:裸のexceptが生む2つの害
害1: バグが握りつぶされる
1def fetch_data(url: str) -> dict:2 try:3 resp = requests.get(url, timeout=5)4 return resp.json()5 except:6 return {} # どんなエラーでも空辞書を返すこのコードは、ネットワークエラーもJSONパースエラーもプログラムのバグ(TypeError、AttributeError)もすべて吸収します。呼び出し側は「空辞書が返った=データがなかった」と解釈し、実はバグだったと気づかないまま運用されます。
害2: KeyboardInterruptとSystemExitも止める
1try:2 long_running_task()3except:4 pass # Ctrl+Cも効かない裸のexcept:はBaseExceptionを捕捉します。KeyboardInterruptやSystemExitまで握りつぶされ、ジョブが止められなくなります。
ステップ1: 全件スキャンで棚卸しする
スキャンスクリプト
1import re2from pathlib import Path3
4PATTERNS = {5 "bare_except": re.compile(r"^\s*except\s*:\s*$"),6 "except_exception": re.compile(r"^\s*except\s+Exception\s*(?:as\s+\w+)?\s*:\s*$"),7 "except_baseexception": re.compile(r"^\s*except\s+BaseException\s*(?:as\s+\w+)?\s*:\s*$"),8}9
10def scan_repo(root: Path) -> list[dict]:11 hits = []12 for py_file in root.rglob("*.py"):13 if "venv" in py_file.parts or ".git" in py_file.parts:14 continue15 try:14 collapsed lines
16 lines = py_file.read_text(encoding="utf-8").splitlines()17 except UnicodeDecodeError:18 continue19 for lineno, line in enumerate(lines, 1):20 for pattern_name, pattern in PATTERNS.items():21 if pattern.match(line):22 hits.append({23 "file": str(py_file.relative_to(root)),24 "line": lineno,25 "pattern": pattern_name,26 "snippet": line.rstrip(),27 })28 break29 return hits出力例:
1src/services/auth.py:47 bare_except except:2src/services/auth.py:112 except_exception except Exception as e:3src/clients/api.py:23 bare_except except:スキャン結果をCSVで棚卸し
1import csv2
3def write_csv(hits: list[dict], out_path: Path):4 with out_path.open("w", newline="") as f:5 writer = csv.DictWriter(f, fieldnames=["file", "line", "pattern", "snippet", "scope", "priority"])6 writer.writeheader()7 for hit in hits:8 hit["scope"] = classify_scope(hit["file"])9 hit["priority"] = priority_of(hit["pattern"], hit["scope"])10 writer.writerow(hit)CSVにすることで、PdMやレビュワーと共有しやすくなります。
ステップ2: スコープ別に分類する
すべてのexcept:が同じ危険度ではありません。どの層のコードかで対処の優先度を決めます。
| スコープ | 例 | 危険度 | 対処優先度 |
|---|---|---|---|
| エントリーポイント | main(), ジョブのトップレベル | 高 | 中(意図的な場合もある) |
| 外部API呼び出し | HTTPクライアント、DBクエリ | 中 | 高 |
| 内部関数 | ビジネスロジック内部 | 高(バグ握りつぶしの温床) | 最高 |
| テストコード | tests/ 以下 | 低 | 低 |
| クリーンアップ | finallyの代替 | 中 | 中 |
スコープ判定は、パスのパターンマッチで自動化できます。
1def classify_scope(file_path: str) -> str:2 if file_path.startswith("tests/"):3 return "test"4 if file_path.endswith("main.py") or "entrypoint" in file_path:5 return "entrypoint"6 if "clients" in file_path or "api" in file_path:7 return "external_io"8 return "internal"ステップ3: 段階的に移行する
優先度の決定
1PRIORITY = {2 ("bare_except", "internal"): 1, # 最優先:バグ握りつぶしの温床3 ("bare_except", "external_io"): 2, # 次点:特定例外に絞れる4 ("except_exception", "internal"): 3, # 3番目:広すぎる捕捉5 ("bare_except", "entrypoint"): 4, # 意図的な場合もあるので要レビュー6 ("except_exception", "external_io"): 5,7 ("bare_except", "test"): 99, # テストは後回し8}置き換えパターン
| Before | After |
|---|---|
except: | except SpecificError: または except Exception: + ログ |
except Exception: pass | except Exception as e: logger.exception(...); raise |
except: return None | except SpecificError: return None |
except: log.error(...) | except Exception as e: log.exception(...) |
log.error()ではなくlog.exception()を使うのがポイントです。exception()はスタックトレースを自動で出力します。
1# Before2try:3 data = fetch()4except:5 logger.error("fetch失敗")6 return None7
8# After9try:10 data = fetch()11except (requests.RequestException, json.JSONDecodeError) as e:12 logger.exception("fetch失敗")13 return None1PRあたりの粒度
ファイル単位・スコープ単位でPRを分けます。1PRで50箇所変更するとレビューできません。
1PR #1: src/services/auth.py の bare_except 6箇所(internalスコープ)2PR #2: src/services/payment.py の bare_except 4箇所3PR #3: src/clients/*.py の bare_except(external_ioスコープ)各PRで単体テストを動かし、振る舞いが変わっていないことを確認します。
ステップ4: CIで新規流入を止める
せっかく整理しても、新しいexcept:が追加されたら意味がありません。CIで検出します。
flake8 + プラグイン
1[flake8]2select = E722 # bare exceptE722は標準で裸のexcept:を検出します。
ruffなら一発
1[tool.ruff.lint]2select = ["E722", "BLE001"] # BLE001: blind-except (except Exception)BLE001はexcept Exception:も検出します。段階移行中は# noqa: BLE001で一時除外しつつ、CIで新規追加を止められます。
pre-commit hookで早期検出
1repos:2 - repo: https://github.com/astral-sh/ruff-pre-commit3 rev: v0.5.04 hooks:5 - id: ruff6 args: [--select, E722,BLE001]ステップ5: AIに置き換えを任せる
大量の箇所をAIに書き換えてもらう場合、スコープ情報を必ず渡すのがコツです。
1以下のexcept:を改善してください。2ファイル: src/clients/payment_api.py (external_io)3行47: except:4文脈: HTTP POSTリクエストの結果をJSONパースする処理5
6方針:7- external_ioなので、具体的な例外(requests.RequestException, json.JSONDecodeError)に絞る8- 捕捉後は logger.exception() でログを残す9- 戻り値は None(既存の挙動を維持)スコープと方針をプロンプトに含めないと、AIは「とりあえずException」と書いてしまいがちです。
実践で学んだこと
1. 「意図的な広い捕捉」は明示コメントで残す
1try:2 plugin.run()3except Exception: # noqa: BLE001 - プラグイン実行中の任意例外を握る(システムは継続させる)4 logger.exception("プラグイン %s が失敗", plugin.name)エントリーポイントやプラグイン機構では「何が起きても継続させる」のが正しい設計の場合があります。その意図をコメントで残し、# noqaで明示的に除外します。
2. スキャン結果を月次で追跡する
12026-01: bare_except = 14222026-02: bare_except = 9832026-03: bare_except = 5442026-04: bare_except = 12減少を可視化するとモチベーションが続きます。逆に増えていたら、CIチェックが漏れている証拠です。
3. テストは一番最後でよい
テストコード内のexcept:は、バグを握りつぶしても影響範囲が限定的です。リソース配分を間違えないようにします。
まとめ
| ステップ | 所要時間(5万行想定) |
|---|---|
| 全件スキャン+CSV化 | 30分 |
| スコープ分類+優先度付け | 1時間 |
| 段階的にPR化 | 1〜2ヶ月 |
| CI/pre-commit設定 | 30分 |
裸のexcept:の一括置換はしてはいけません。挙動が変わって本番事故につながります。「スキャンして棚卸し→スコープ分類→優先度順にPR→CIで新規を止める」の4段階で、計画的に減らしていきます。
そして、AIに置き換えを任せるなら、スコープと方針を必ずプロンプトに含めること。これだけで生成コードの質が大きく変わります。