生產環境的 log 裡出現:

error: connection refused

這個錯誤告訴你什麼?幾乎沒有。哪個服務?哪個 IP?哪個操作在等這個連線?你要去查 stack trace,查呼叫鏈,才能知道這個錯誤在說什麼。

如果 log 是這樣:

error: process_payment: charge_card: connect to payment gateway: connection refused

你立刻知道:支付流程的 charge_card 步驟連不到支付閘道。不需要查 stack trace,30 秒就能判斷是不是支付閘道問題。

這就是錯誤包裝的作用。


什麼是錯誤包裝

錯誤包裝(error wrapping):在把錯誤往上傳之前,加上當前層的脈絡資訊,形成一個錯誤鏈。

原始錯誤是 leaf,每經過一層函式都加上一個 wrapper,最終呼叫方看到的是帶完整上下文的錯誤。


Go 的錯誤包裝

Go 1.13 加入了標準的錯誤包裝語法:

// 包裝:加上脈絡,保留原始錯誤
return fmt.Errorf("charge_card: %w", err)
 
// 解包:取回原始錯誤(用於 type checking)
var netErr *net.OpError
if errors.As(err, &netErr) {
    // 可以存取 netErr 的欄位
}
 
// 判斷是不是特定錯誤
if errors.Is(err, sql.ErrNoRows) {
    // 處理 not found
}

%w 是關鍵——它不只是把 err.Error() 插進字串,而是把整個 error 值包進去,保留了原始錯誤的型別和值,讓 errors.Aserrors.Is 能穿透包裝層找到原始錯誤。

一個多層的包裝鏈:

func processPayment(userID int, amount float64) error {
    if err := chargeCard(userID, amount); err != nil {
        return fmt.Errorf("processPayment: %w", err)
    }
    return nil
}
 
func chargeCard(userID int, amount float64) error {
    if err := connectGateway(); err != nil {
        return fmt.Errorf("chargeCard: %w", err)
    }
    return nil
}
 
func connectGateway() error {
    return fmt.Errorf("connectGateway: %w", &net.OpError{
        Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED,
    })
}
 
// 最終錯誤訊息:
// "processPayment: chargeCard: connectGateway: dial tcp: connection refused"

Python 的錯誤鏈

Python 3 的 exception chaining 用 raise ... from ...

try:
    connect_to_gateway()
except ConnectionError as e:
    raise PaymentError("charge_card failed") from e

from e 把原始 exception 存在新 exception 的 __cause__ 屬性,traceback 會顯示完整的鏈:

ConnectionError: connection refused

The above exception was the direct cause of the following exception:

PaymentError: charge_card failed

如果你用 raise PaymentError("...") 而不加 from e,Python 還是會在 traceback 裡顯示原始 exception(__context__),但語意上是「在處理原始 exception 時發生了新 exception」,而不是「這個 exception 是由那個 exception 造成的」。


Rust 的錯誤包裝

Rust 生態系最常用 thiserroranyhow 兩個 crate:

use anyhow::{Context, Result};
 
fn process_payment(user_id: u64, amount: f64) -> Result<()> {
    charge_card(user_id, amount)
        .context("process_payment")?;
    Ok(())
}
 
fn charge_card(user_id: u64, amount: f64) -> Result<()> {
    connect_gateway()
        .context("charge_card")?;
    Ok(())
}

anyhow::Context 讓你用 .context("description") 在任何 Result 上加脈絡,和 Go 的 %w 概念類似。


什麼時候不要包裝

不是所有的錯誤都值得包裝。包裝的目的是「加上有用的脈絡」,加沒用的脈絡是噪音:

// 沒有意義的包裝——呼叫者能從錯誤本身知道發生什麼
if err := json.Unmarshal(data, &result); err != nil {
    return fmt.Errorf("json.Unmarshal: %w", err)  // 這個 prefix 完全多餘
}
 
// 有意義的包裝——加上讓 debug 有用的上下文
if err := json.Unmarshal(data, &result); err != nil {
    return fmt.Errorf("unmarshal user profile (user_id=%d): %w", userID, err)
}

好的脈絡資訊是「你在 debug 時會想知道但原始錯誤沒有的東西」——操作的對象是什麼、在什麼業務步驟裡、相關的 ID 是什麼。


錯誤包裝和 Structured Logging

現代後端服務通常用 structured logging(JSON log),錯誤包裝的資訊可以直接放在 log 欄位裡:

log.Error("payment failed",
    "error", err,
    "user_id", userID,
    "amount", amount,
    "step", "charge_card",
)

這比把所有脈絡都塞進 error message 更適合機器解析——你可以直接在 log 工具裡過濾 step=charge_card 的所有錯誤,而不是做 string matching。


Sentinel Error vs Error Type

在設計 API 的錯誤回傳時,有兩種常見策略:

Sentinel error(Go)

預先定義的錯誤值,用 errors.Is 比較:

var ErrNotFound = errors.New("not found")
var ErrPermissionDenied = errors.New("permission denied")
 
// 呼叫方:
if errors.Is(err, ErrNotFound) {
    return http.StatusNotFound, nil
}

Error type

自定義的 error struct,帶有額外欄位:

type NotFoundError struct {
    ResourceType string
    ID           int
}
 
func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s (id=%d) not found", e.ResourceType, e.ID)
}
 
// 呼叫方:
var notFound *NotFoundError
if errors.As(err, &notFound) {
    log.Printf("missing resource: %s/%d", notFound.ResourceType, notFound.ID)
    return http.StatusNotFound, nil
}

Error type 適合當你需要從錯誤裡取出結構化資訊的情況;sentinel error 適合當你只需要知道「是不是這種錯誤」而不需要額外資訊。

Recover