# 错误包装

Source: https://codewiki.com/zh/go/error-wrapping/

> - **what**: 错误包装（error wrapping）在保留原始错误的同时添加操作上下文，让消息适合排查，也让程序仍能识别底层原因。
> - **trap**: 用 `%v` 代替 `%w`、直接比较最外层错误，或无意中包装依赖库错误，都会破坏或扩大调用方依赖的契约。
> - **fix**: 先决定调用方应识别什么，再用 `%w` 建立错误树，并通过 `errors.Is`、`errors.As` 和边界测试验证该契约。

## 是什么，为什么存在

错误包装（error wrapping）是把一个错误放入另一个错误中，同时为当前操作添加上下文。
格式化后的消息可以说明哪项操作失败，而被包装的值仍保留自己的身份、类型和字段。

如果每一层只原样返回错误，最终日志可能只剩下 `permission denied` 或 `record not found`，无法指出失败发生在哪次业务操作中。
如果每一层都创建全新的文本错误，消息虽然变长，程序却无法再可靠地判断原始原因。
包装把这两个需求放在同一条路径上：文本供人阅读，结构供程序检查。

Go 代码在文件访问、数据库适配器、HTTP 客户端、解析器和服务边界中经常包装错误。
当一层知道当前操作，下一层知道更具体的失败原因时，就适合增加一层上下文。
没有新信息可补充时，直接返回错误通常更清楚。

错误结构也是 API 契约。
公开函数用 `%w` 包装某个值后，调用方就可能通过 `errors.Is` 或 `errors.As` 依赖它。
因此，选择 `%w` 不是单纯的格式化决定；它决定了哪些原因会穿过包边界。

| 调用方需要 | 适合公开的结构 |
| --- | --- |
| 只需知道成功或失败 | 非 nil 错误 |
| 识别稳定的失败类别 | 哨兵错误（sentinel error）与 `errors.Is` |
| 读取路径、字段或状态码 | 自定义错误类型与 `errors.As` |
| 同时识别多个独立失败 | 多错误包装与 `errors.Join` |

## 工作原理

`error` 接口只有 `Error() string` 方法，但包装协议还约定了 `Unwrap() error` 和 `Unwrap() []error` 两种方法。
实现前者的值包装一个子错误，实现后者的值包装零个或多个子错误。
连续展开后得到的结构称为错误链（error chain）；存在多子节点时，它实际上是一棵树。

`fmt.Errorf("load profile: %w", err)` 会返回一个错误，其 `Error` 消息包含 `err`，其 `Unwrap` 方法则返回 `err`。
如果格式字符串含有多个 `%w`，结果会通过 `Unwrap() []error` 按参数出现顺序公开所有操作数。
`%w` 在可见文本上的格式与 `%v` 相同，差别只存在于可遍历结构中。

`errors.Is(err, target)` 会以前序深度优先方式检查错误树。
它先检查当前错误是否等于目标，或当前错误的 `Is(error) bool` 方法是否报告浅层匹配，然后再访问子错误。
经过多层 `%w` 包装后，稳定的哨兵错误仍然可以被找到。

`errors.As(err, &target)` 使用同样的遍历顺序寻找可赋给目标类型的错误。
调用方先声明目标槽位，再把该槽位的指针传给 `As`；匹配成功时，`As` 会写入找到的错误。
它适合读取自定义错误公开的结构化字段，而不是解析 `Error()` 返回的字符串。

`errors.Join` 会丢弃 nil 输入，并把剩余错误放在同一父节点下。
所有输入都是 nil 时，它返回 nil。
`errors.Is` 和 `errors.As` 会访问每个非 nil 子错误，但 `errors.Unwrap` 只识别 `Unwrap() error`，不会替你枚举合并节点。

一次典型的传播过程是：

1. 底层操作返回具体错误或哨兵错误。
2. 中间层用 `%w` 添加只有这一层知道的操作上下文。
3. 边界层用 `errors.Is` 或 `errors.As` 分类失败。
4. 拥有处理结果的边界记录一次日志、返回状态或决定重试。

这套机制不会自动定义错误契约。
包作者仍要决定保留底层原因、转换为领域错误，还是把原因隐藏在包内部。
调用方只能依赖文档与测试明确承诺的匹配行为。

## 示例

### 包装并匹配哨兵错误

第一层说明查找了哪个订单，第二层说明当时正在加载收据。
两层消息都保留下来，调用方仍能识别稳定的 `ErrOrderMissing` 类别。

<!-- quick -->

```go
package main

import (
	"errors"
	"fmt"
)

var ErrOrderMissing = errors.New("order missing")

func lookupOrder(id string) error {
	if id != "ORD-42" {
		return fmt.Errorf("lookup order %q: %w", id, ErrOrderMissing)
	}
	return nil
}

func loadReceipt(id string) error {
	if err := lookupOrder(id); err != nil {
		return fmt.Errorf("load receipt: %w", err)
	}
	return nil
}

func main() {
	err := loadReceipt("ORD-9")
	fmt.Println(err)
	fmt.Println("is missing:", errors.Is(err, ErrOrderMissing))
	fmt.Println("equal:", err == ErrOrderMissing)
}
```

```text
load receipt: lookup order "ORD-9": order missing
is missing: true
equal: false
```

<!-- /quick -->

直接比较看到的是最外层包装器，所以结果为 false。
`errors.Is` 会继续访问两个包装层，最终找到哨兵错误。
这正是调用方想问的语义问题，而不是两个接口值是否相等。

错误消息从外到内读起来像一条操作路径。
每一层只添加自己能说明的信息，避免出现「failed to handle error」这类没有诊断价值的填充文本。

### 用 `errors.As` 读取字段

类别判断不够用时，自定义错误可以携带字段。
它实现 `Unwrap` 后，具体类型与更宽泛的哨兵类别可以同时存在于一棵错误树中。

```go
package main

import (
	"errors"
	"fmt"
	"strings"
)

var ErrInvalidField = errors.New("invalid field")

type FieldError struct {
	Field string
	Value string
	Err   error
}

func (e *FieldError) Error() string {
	return fmt.Sprintf("%s=%q: %v", e.Field, e.Value, e.Err)
}

func (e *FieldError) Unwrap() error { return e.Err }

func validateEmail(email string) error {
	if !strings.Contains(email, "@") {
		return &FieldError{"email", email, ErrInvalidField}
	}
	return nil
}

func main() {
	err := fmt.Errorf("create account: %w", validateEmail("alex.example.com"))
	var fieldErr *FieldError

	fmt.Println("invalid:", errors.Is(err, ErrInvalidField))
	if errors.As(err, &fieldErr) {
		fmt.Printf("field=%s value=%s\n", fieldErr.Field, fieldErr.Value)
	}
}
```

```text
invalid: true
field=email value=alex.example.com
```

`errors.Is` 负责回答「是否属于无效字段」，`errors.As` 则取得 `*FieldError` 的字段。
两种检查关注不同的契约，因此可以在同一个调用位置组合使用。

这里的 `Error` 方法用 `%v` 格式化自身保存的原因是正确的。
建立包装关系的是 `Unwrap` 方法；在 `Error()` 内再次使用 `%w` 没有意义，因为 `%w` 只对 `fmt.Errorf` 的返回值建立结构。

### 定义浅层的自定义匹配

有些包希望用稳定代码分类错误，同时让每个实例保留不同的操作和原因。
自定义 `Is` 方法可以把模板错误映射到这种类别，但它只应比较当前接收者和目标。

```go
package main

import (
	"errors"
	"fmt"
)

var ErrRowAbsent = errors.New("row absent")

type CodeError struct {
	Code string
	Op   string
	Err  error
}

func (e *CodeError) Error() string {
	return fmt.Sprintf("%s [%s]: %v", e.Op, e.Code, e.Err)
}

func (e *CodeError) Unwrap() error { return e.Err }

func (e *CodeError) Is(target error) bool {
	want, ok := target.(*CodeError)
	return ok && e.Code == want.Code
}

var ErrNotFound = &CodeError{Code: "NOT_FOUND"}

func main() {
	err := &CodeError{
		Code: "NOT_FOUND",
		Op:   `find user "U-9"`,
		Err:  ErrRowAbsent,
	}

	fmt.Println(err)
	fmt.Println("category:", errors.Is(err, ErrNotFound))
	fmt.Println("cause:", errors.Is(err, ErrRowAbsent))
}
```

```text
find user "U-9" [NOT_FOUND]: row absent
category: true
cause: true
```

第一次匹配由 `CodeError.Is` 根据代码完成。
第二次匹配不需要 `Is` 方法递归搜索；标准库在检查当前节点后会调用 `Unwrap`，再找到 `ErrRowAbsent`。

这种模板匹配会扩大公开契约。
一旦调用方依赖 `NOT_FOUND` 的匹配语义，修改代码值或 `Is` 规则就可能成为破坏性变更。

### 合并多个错误分支

验证器可以一次返回多个独立失败。
`errors.Join` 保留每个分支的包装上下文，调用方无需解析换行后的消息。

```go
package main

import (
	"errors"
	"fmt"
	"strings"
)

var (
	ErrNameRequired = errors.New("name required")
	ErrEmailInvalid = errors.New("email invalid")
)

func validateProfile(name, email string) error {
	var failures []error
	if strings.TrimSpace(name) == "" {
		failures = append(failures,
			fmt.Errorf("name: %w", ErrNameRequired))
	}
	if !strings.Contains(email, "@") {
		failures = append(failures,
			fmt.Errorf("email %q: %w", email, ErrEmailInvalid))
	}
	return errors.Join(failures...)
}

func main() {
	err := validateProfile("", "alex.example.com")
	if err == nil {
		fmt.Println("valid")
		return
	}

	fmt.Println(err)
	fmt.Println("name:", errors.Is(err, ErrNameRequired))
	fmt.Println("email:", errors.Is(err, ErrEmailInvalid))
	fmt.Println("single unwrap is nil:", errors.Unwrap(err) == nil)
}
```

```text
name: name required
email "alex.example.com": email invalid
name: true
email: true
single unwrap is nil: true
```

合并值的文本把两个子错误放在不同行，但格式不是分类 API。
两个 `errors.Is` 调用分别沿对应分支找到目标。

最后一行展示了一个容易误判的细节：`errors.Unwrap` 不处理 `Unwrap() []error`。
需要分类时直接使用 `Is` 或 `As`；需要枚举整棵树时，诊断代码必须明确处理两种 `Unwrap` 签名。

## 陷阱

### 用 `%v` 截断错误树

> **陷阱:** `fmt.Errorf("read config: %v", err)` 保留了可见消息，却没有建立包装关系；后续 `errors.Is` 和 `errors.As` 看不到 `err`。

**修复方法：** 如果底层原因属于公开契约，就改用 `%w`，并添加经过至少一层包装的测试。
如果原因属于实现细节，则有意转换为领域错误，不要让 `%v` 假装成仍可遍历的包装。

### 只检查最外层值

> **陷阱:** `err == target` 和 `err.(*Type)` 只检查最外层错误，增加任何包装上下文后都可能失效。

**修复方法：** 对稳定值使用 `errors.Is`，对结构化类型使用 `errors.As`。
只有明确需要判断最外层身份时才直接比较，而且测试应说明这种限制。

### 包装 nil 错误

> **陷阱:** `fmt.Errorf` 不会因为 `%w` 对应的错误为 nil 就返回 nil；无条件包装会把成功路径变成非 nil 错误。

**修复方法：** 在包装前写出 `if err != nil` 分支，成功路径直接返回 nil。
特别检查短函数末尾的 `return fmt.Errorf("operation: %w", err)`：缺少前置判断时，操作成功也会返回非 nil 错误。

### 给 `errors.As` 错误的目标

> **陷阱:** 目标为 nil，或不是指向错误类型或接口的非 nil 指针时，`errors.As` 会 panic；指针接收者错误尤其容易多写或少写一层指针。

**修复方法：** 先声明 `var target *PathError`，再调用 `errors.As(err, &target)`。
运行 `go vet`，并用包含该类型和不包含该类型的错误树覆盖这条分支。

### 无意公开依赖错误

> **陷阱:** 包装 `sql.ErrNoRows` 或供应商 SDK 的具体类型后，调用方可能把该匹配行为当作你的 API，即使你原本只想保留诊断信息。

**修复方法：** 在包边界决定调用方是否应该依赖该原因。
如果不应该，就转换为自己的哨兵错误或类型；如果应该，就写入文档，并用包边界测试固定行为。

### 把错误链当作线性列表

> **陷阱:** 多个 `%w`、`errors.Join` 和自定义 `Unwrap() []error` 都会产生分支；反复调用 `errors.Unwrap` 既看不到这些分支，也不能可靠地表示整棵树。

**修复方法：** 分类时让 `errors.Is` 和 `errors.As` 完成标准遍历。
只有诊断工具确实需要访问所有节点时才自行遍历，并分别处理单子节点和多子节点接口。

<!-- deep -->

## 错误树的遍历

标准库把根错误本身也视为树的一部分。
`errors.Is` 和 `errors.As` 先检查当前节点，再以前序深度优先顺序访问每个子节点。
因此，最外层自定义匹配或可赋值类型会先于更深层的同类节点生效。

单个 `%w` 通常产生实现 `Unwrap() error` 的包装器。
多个 `%w` 产生实现 `Unwrap() []error` 的包装器，子节点顺序与操作数顺序相同。
`errors.Join` 也使用多子节点形式，并忽略传入的 nil 错误。

如果多个分支都含有可赋给同一目标的类型，`errors.As` 只写入遍历时遇到的第一个。
不要把这个顺序当成面向用户的优先级模型。
需要展示有序验证结果时，应另外保留有序数据，而不是从错误树反推界面顺序。

`errors.Unwrap` 是为单子节点便利函数设计的。
它只调用 `Unwrap() error`，遇到多子节点包装器时返回 nil。
这不是数据丢失；子节点仍然存在，`Is` 和 `As` 仍会遍历它们。

自定义 `Unwrap() []error` 不应返回含 nil 的切片。
包装器还应形成有限结构；让节点直接或间接解包到自身，会使遍历无法正常结束。
除非你在编写聚合库，否则优先使用 `fmt.Errorf` 和 `errors.Join` 提供的实现。

## 自定义包装器的契约

自定义错误通常在需要结构化字段时才值得创建。
`Error()` 负责可读消息，`Unwrap` 公开原因，导出的字段或方法则公开调用方可以读取的数据。
三个部分应描述同一个失败，而不是维护互相矛盾的副本。

实现 `Is(target error) bool` 可以提供超越接口相等性的匹配。
该方法应只浅层比较接收者与目标，不应调用 `Unwrap` 或递归调用 `errors.Is`。
标准库会负责遍历子树；在自定义方法中再次遍历会重复工作，并让复杂树的成本迅速增加。

自定义 `Is` 还要避免意外放宽匹配。
常见做法是把目标值中的零值字段解释为通配符，但这必须有文档说明。
匹配规则一旦公开，调用方就可能在控制流中依赖它。

实现 `As(target any) bool` 的门槛更高，因为方法需要验证目标形状并完成赋值。
大多数自定义错误无需实现它；正常的类型可赋值检查已经能处理指针错误和接口。
只有包装器需要呈现不同抽象类型，而且该行为能形成稳定契约时，才考虑自定义 `As`。

一个错误可以同时提供类别、详情和原因。
例如，`FieldError` 的具体类型提供字段，`ErrInvalidField` 提供稳定类别，底层原因还可以提供更具体诊断。
公开多少层取决于调用方需要作出的决定，而不是可以包装多少层。

边界转换可能有意结束一条链。
如果存储层细节不应离开包，服务层可以返回自己的哨兵错误，并把原始错误交给内部遥测边界。
不要通过解析原始消息来复制细节，也不要在每一层记录后继续返回同一个错误。

## 错误契约的测试

测试应从调用方看到的包边界开始，而不是只测试创建哨兵错误的底层函数。
这样，某一层把 `%w` 改成 `%v` 时，测试才会失败。
至少经过一层真实包装，再断言 `errors.Is` 或 `errors.As` 的结果。

对于哨兵错误，断言 `errors.Is(got, want)`。
对于自定义类型，先用 `errors.As` 取得目标，再检查文档承诺的字段。
不要比较整个结构体，因为未公开的诊断字段以后可能合理变化。

失败测试也应包含不匹配目标。
过于宽松的自定义 `Is` 可能让所有同类型错误都匹配，而只有正向用例无法发现这个问题。
多错误测试则应分别检查每个预期分支，并验证无失败输入时返回真正的 nil。

成功路径必须单独测试。
无条件调用 `fmt.Errorf`，或返回保存 nil 指针的 `error` 接口，都会产生非 nil 错误。
先断言 `err == nil`，再使用结果，可以更快暴露这类生成代码缺陷。

一组边界测试至少覆盖：

- 一层和多层 `%w` 包装后仍能匹配的目标。
- 使用 `%v` 或有意转换后不应再匹配的内部原因。
- `errors.As` 成功时得到的具体类型和公开字段。
- `errors.Join` 中的每个分支，以及全部输入为 nil 的情况。

错误消息只有在本身属于公开输出时才做精确比较。
命令行诊断可以使用 golden test，库的分类测试则应依赖 `Is`、`As` 和公开字段。
这样既保留重写消息的自由，也固定真正的程序契约。

<!-- /deep -->

[检查点: go/error-wrapping](https://codewiki.com/zh/go/error-wrapping/#checkpoint)

## 延伸阅读

- [Go `errors` 包](https://pkg.go.dev/errors)
- [Go `fmt.Errorf` 文档](https://pkg.go.dev/fmt#Errorf)
- [Go 博客：Working with Errors in Go 1.13](https://go.dev/blog/go1.13-errors)
- [Go 1.20 发布说明：多错误](https://go.dev/doc/go1.20#errors)
- [Go 1.27 发布说明](https://go.dev/doc/go1.27)
