5.5 頂層例外處理機制
前面的章節介紹了異常處理的基本語法和 (bool, str) 返回值模式。本章進入實務層面:當你有 44 個 Hook 腳本,每個都可能在不同地方失敗時,如何建立一套統一的錯誤基礎設施?
這是 W22-W25 開發週期中建立的機制,解決的核心問題是:
Hook 失敗時,錯誤不能靜默消失,也不能讓整個工作流程崩潰。
問題:44 個 Hook,44 種錯誤處理方式
在統一之前,每個 Hook 腳本各自處理例外:
1# hook_a.py — 用 try-except 包整個 main
2def main():
3 try:
4 do_work()
5 except Exception as e:
6 print(f"Error: {e}")
7 sys.exit(1)
8
9# hook_b.py — 完全沒有錯誤處理
10def main():
11 do_work() # 任何異常直接讓腳本 crash
12
13# hook_c.py — 錯誤寫到檔案但用戶看不到
14def main():
15 try:
16 do_work()
17 except Exception as e:
18 with open("error.log", "a") as f:
19 f.write(str(e))
20 # 用戶完全不知道出錯了這造成三個問題:
- 行為不一致:有的 Hook 失敗會中斷流程,有的靜默吞掉
- 重複程式碼:每個 Hook 各寫一套 try-except
- 錯誤不可見:某些 Hook 靜默失敗了好幾個 session 才被發現
解決方案:run_hook_safely
hook_utils.py 提供了一個頂層例外處理器,所有 Hook 統一使用:
1def run_hook_safely(main_func: Callable[[], int], hook_name: str) -> int:
2 """安全執行 Hook 函式,頂層例外處理
3
4 Args:
5 main_func: Hook 主入口函式,必須返回 int
6 hook_name: Hook 識別名稱
7
8 Returns:
9 int: main_func 的返回值(正常),或 1(異常)
10 """
11 logger = setup_hook_logging(hook_name)
12
13 try:
14 exit_code = main_func()
15 # 驗證返回值是整數
16 if not isinstance(exit_code, int):
17 try:
18 exit_code = int(exit_code)
19 except (ValueError, TypeError):
20 exit_code = 0
21 return exit_code
22 except (KeyboardInterrupt, SystemExit):
23 raise # 這兩個不攔截
24 except Exception:
25 tb_str = traceback.format_exc()
26 _log_exception(logger, hook_name, tb_str)
27 return EXIT_ERROR每個 Hook 的使用方式
1#!/usr/bin/env python3
2import sys
3sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
4from hook_utils import run_hook_safely
5
6def main() -> int:
7 # 專注於業務邏輯,不需要處理頂層例外
8 data = json.load(sys.stdin)
9 result = validate(data)
10 print(json.dumps(result))
11 return 0
12
13if __name__ == "__main__":
14 sys.exit(run_hook_safely(main, "my-hook-name"))設計解析
為什麼用 Callable 包裝?
run_hook_safely 接收一個函式,而不是直接包裝程式碼區塊。這個設計有三個好處:
1# 方式 A:直接包裝(不採用)
2try:
3 # 所有程式碼放在這裡
4 data = read_input()
5 result = process(data)
6 output(result)
7except Exception:
8 handle_error()
9
10# 方式 B:函式包裝(採用)
11def main() -> int:
12 data = read_input()
13 result = process(data)
14 output(result)
15 return 0
16
17run_hook_safely(main, "hook-name")| 比較 | 方式 A | 方式 B |
|---|---|---|
| 可重用性 | 每個 Hook 各寫一次 | run_hook_safely 寫一次,所有 Hook 共用 |
| 測試性 | 無法單獨測試錯誤處理 | 可以測試 run_hook_safely 的行為 |
| 關注點分離 | 業務邏輯和錯誤處理混在一起 | Hook 只寫業務邏輯,錯誤處理交給框架 |
這就是高階函式在實務中的應用。
為什麼 KeyboardInterrupt 和 SystemExit 要特別處理?
1except (KeyboardInterrupt, SystemExit):
2 raise # 不攔截,直接往上傳
3except Exception:
4 # 只攔截「普通」的程式錯誤Python 的例外繼承結構:
1BaseException
2├── KeyboardInterrupt ← Ctrl+C
3├── SystemExit ← sys.exit()
4└── Exception ← 所有「普通」例外的父類別
5 ├── ValueError
6 ├── TypeError
7 ├── FileNotFoundError
8 └── ...KeyboardInterrupt 和 SystemExit 不是程式錯誤,它們是控制信號:
KeyboardInterrupt:用戶按了 Ctrl+C,意圖是終止程式SystemExit:程式碼呼叫了sys.exit(),意圖是正常退出
如果攔截了這兩個,用戶按 Ctrl+C 程式不會停,sys.exit(0) 也不會退出。這違反了「最小驚訝原則」。
返回值型別驗證
1if not isinstance(exit_code, int):
2 try:
3 exit_code = int(exit_code)
4 except (ValueError, TypeError):
5 exit_code = 0這段防護處理的是:萬一某個 Hook 的 main() 返回了非整數(例如 None 或字串)。
| main() 返回值 | 處理結果 | 說明 |
|---|---|---|
0 | 0 | 正常 |
1 | 1 | 正常 |
None | 0 | 忘記寫 return |
"0" | 0 | 字串轉整數 |
"abc" | 0 | 無法轉換,視為成功 |
stderr vs stdout:Hook 系統的特殊規則
Claude Code 的 Hook 系統對輸出有特殊解讀:
| 輸出管道 | Claude Code 的解讀 |
|---|---|
stdout | 正常訊息,顯示為 hook success |
stderr | 錯誤訊息,顯示為 hook error |
這個行為影響了 _log_exception 的設計:
1def _log_exception(logger, hook_name, tb_str):
2 # 1. 寫入日誌檔案(給開發者事後分析)
3 try:
4 logger.critical(f"Unhandled exception in {hook_name}")
5 logger.critical(tb_str)
6 except Exception as logging_error:
7 print(f"Failed to log exception: {logging_error}", file=sys.stdout)
8 print(tb_str, file=sys.stdout)
9
10 # 2. 輸出到 stderr(讓用戶知道出錯了)
11 print(
12 f"[Hook Error] {hook_name} failed unexpectedly. "
13 f"Check hook logs for details.",
14 file=sys.stderr
15 )第二行的 stderr 輸出是刻意的。在 W25-005 之前,所有錯誤只寫入日誌檔案,導致 7 個 Hook 靜默失敗了至少 2 個 session 才被發現。加入 stderr 輸出後,用戶會立刻看到 hook error 提示。
實際案例
1# 用戶看到的訊息(W25-005 之前)
2SessionStart:startup hook success: Success ← 看起來正常
3SessionStart:startup hook success: Success
4# 實際上有 7 個 Hook 在靜默失敗
5
6# W25-005 之後
7SessionStart:startup hook success: Success
8SessionStart:startup hook error: [Hook Error] acceptance-gate-hook failed unexpectedly.
9# 用戶立刻知道有問題這個教訓的完整記錄在 IMP-003 錯誤模式中,模組七的作用域迴歸案例研究有詳細分析。
日誌系統配置
run_hook_safely 背後依賴 setup_hook_logging 建立日誌系統:
1def setup_hook_logging(hook_name: str) -> logging.Logger:
2 """建立並設定 Hook 日誌系統
3
4 功能:
5 - 建立日誌目錄 .claude/hook-logs/{hook_name}/
6 - 建立日誌檔案 {hook_name}-{YYYYMMDD-HHMMSS}.log
7 - 配置 FileHandler + StreamHandler
8 """
9 sanitized_name = _sanitize_hook_name(hook_name)
10 root_dir = _find_project_root()
11 log_base_dir = root_dir / ".claude" / "hook-logs" / sanitized_name
12
13 try:
14 log_base_dir.mkdir(parents=True, exist_ok=True)
15 except OSError:
16 return _create_fallback_logger(hook_name)
17
18 logger = logging.getLogger(hook_name)
19 _clear_logger_handlers(logger)
20 logger.setLevel(logging.DEBUG)
21
22 is_debug = os.getenv("HOOK_DEBUG", "").lower() == "true"
23 _setup_logger_handlers(logger, log_base_dir, sanitized_name, is_debug)
24 return loggerFallback 策略
注意 except OSError 的處理:目錄建立失敗時不拋出異常,而是返回一個只有 StreamHandler 的 fallback logger。這確保即使檔案系統出問題,日誌功能仍然可用(只是沒有檔案記錄)。
這體現了 5.1 異常處理策略 中「策略 4:使用預設值」的原則。
統一前後的對比
| 指標 | 統一前(v0.28.0) | 統一後(v0.31.0) |
|---|---|---|
| 錯誤處理方式 | 44 種不同實作 | 1 個 run_hook_safely |
| 靜默失敗風險 | 高(多個 Hook 實際靜默失敗中) | 低(stderr 強制可見) |
| 日誌格式 | 不一致 | 統一時間戳、格式、目錄結構 |
| 新增 Hook 所需程式碼 | ~15 行錯誤處理 | 1 行 run_hook_safely 呼叫 |
思考題
run_hook_safely為什麼選擇返回EXIT_ERROR(整數 1)而不是拋出異常?- 如果
_log_exception本身也拋出異常(例如磁碟已滿),會發生什麼? - 為什麼
_create_fallback_logger只配StreamHandler而不是直接返回None?
實作練習
- 寫一個類似
run_hook_safely的裝飾器版本,讓@safe_hook就能保護函式 - 擴展
_log_exception,讓它在記錄日誌的同時發送通知(例如寫入一個.alert檔案) - 修改
setup_hook_logging,加入日誌檔案大小限制(使用RotatingFileHandler)
上一章:Mock 與測試隔離 相關:作用域迴歸案例研究 — 這套機制如何在一次重構中暴露出潛在問題