前面的章節介紹了異常處理的基本語法和 (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        # 用戶完全不知道出錯了

這造成三個問題:

  1. 行為不一致:有的 Hook 失敗會中斷流程,有的靜默吞掉
  2. 重複程式碼:每個 Hook 各寫一套 try-except
  3. 錯誤不可見:某些 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 只寫業務邏輯,錯誤處理交給框架

這就是高階函式在實務中的應用。

為什麼 KeyboardInterruptSystemExit 要特別處理?

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    └── ...

KeyboardInterruptSystemExit 不是程式錯誤,它們是控制信號

  • 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() 返回值處理結果說明
00正常
11正常
None0忘記寫 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 logger

Fallback 策略

注意 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 呼叫

思考題

  1. run_hook_safely 為什麼選擇返回 EXIT_ERROR(整數 1)而不是拋出異常?
  2. 如果 _log_exception 本身也拋出異常(例如磁碟已滿),會發生什麼?
  3. 為什麼 _create_fallback_logger 只配 StreamHandler 而不是直接返回 None

實作練習

  1. 寫一個類似 run_hook_safely 的裝飾器版本,讓 @safe_hook 就能保護函式
  2. 擴展 _log_exception,讓它在記錄日誌的同時發送通知(例如寫入一個 .alert 檔案)
  3. 修改 setup_hook_logging,加入日誌檔案大小限制(使用 RotatingFileHandler

上一章:Mock 與測試隔離 相關:作用域迴歸案例研究 — 這套機制如何在一次重構中暴露出潛在問題