生產環境的 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.As 和 errors.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 efrom 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 生態系最常用 thiserror 和 anyhow 兩個 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, ¬Found) {
log.Printf("missing resource: %s/%d", notFound.ResourceType, notFound.ID)
return http.StatusNotFound, nil
}Error type 適合當你需要從錯誤裡取出結構化資訊的情況;sentinel error 適合當你只需要知道「是不是這種錯誤」而不需要額外資訊。
→ Recover