登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

设计可重试错误与永久错误的稳定边界

来源:17golang原创

时间:2026-10-07 15:32:02 263浏览 收藏

可重试错误与永久错误的边界,应该由领域语义决定,而不是由错误字符串、某个驱动类型或“只要失败就再试一次”决定。我的做法是:先用 %w 保留错误树,再用 errors.Is、errors.As 查询稳定语义;分类器只回答“能否重试”,退避器再处理次数、等待和预算。参数、权限、上下文取消等错误默认永久停止,瞬时故障也只有在操作可幂等重放时才进入重试。

Go 官方错误处理说明:https://go.dev/blog/go1.13-errors

稳定边界不是“列出所有网络错误”,而是让上层只依赖少量、可测试、不会随底层库替换而变化的错误语义。

我踩过的坑:把所有 error 都塞进重试器

我第一次给批量同步任务加重试时,思路很直接:函数返回非空 error,就指数退避后再调用。它对偶发超时确实有用,却很快把另一类问题放大了。无效参数被重复提交,权限错误白白等完整个预算,调用方已经取消的任务仍在后台继续,日志里还堆满了同一句失败消息。

后来我又试过按字符串判断,例如包含 timeout 就重试,包含 invalid 就停止。这种方案更脆:底层库升级会改文案,中文化会改文案,同一个超时也可能发生在不可安全重放的提交之后。错误文本是给人看的上下文,不应成为机器协议。

真正需要拆开的其实是三个问题:错误是什么语义、这次操作能否安全重放、在当前请求预算里要怎样重试。只有第一个属于错误分类器;第二个属于业务幂等设计;第三个属于重试策略。

先保留错误树,再谈分类

Go 官方在 1.13 中引入了 Unwrap 约定、errors.Is、errors.As 和 fmt.Errorf 的 %w。当前标准库把连续解包视为错误树:一个错误既可以包装一个错误,也可以通过 Unwrap() []error 包装多个错误;errors.Is 与 errors.As 会遍历它。

func loadProfile(ctx context.Context, id string) error {
    if id == "" {
        return fmt.Errorf("load profile: %w", ErrInvalidInput) // 保留稳定的参数错误语义
    }

    if err := repository.Load(ctx, id); err != nil {
        return fmt.Errorf("load profile %q: %w", id, err) // 增加定位上下文并保留底层错误树
    }
    return nil
}

这里最重要的不是格式化技巧,而是 API 承诺。Go 官方文章明确提醒:一旦用 %w 暴露某个底层错误,调用方就可能依赖它,这个错误会成为 API 的一部分。如果数据库只是内部实现,我通常会先把驱动错误转换为自己的领域错误,再向外包装,避免换数据库时破坏调用方。

errors.Is 适合查询哨兵语义,errors.As 适合取得带字段的类型。它们都比直接比较或顶层类型断言稳定,因为外层可以继续增加操作名、资源 ID 和节点信息。

建立可重试与永久错误的领域模型

我的错误模型不会把每个供应商状态码都公开出去,而是只公开少量决策需要的语义。下面这个类型既保留原因,又携带是否可重试的领域提示:

var (
    ErrInvalidInput = errors.New("invalid input") // 调整请求后才能成功
    ErrForbidden    = errors.New("forbidden")     // 当前身份没有执行权限
)

type RetryHint interface {
    error
    Retryable() bool // 只表达语义,不负责等待或再次调用
}

type OpError struct {
    Op    string
    Retry bool
    Err   error
}

func (e *OpError) Error() string {
    return e.Op + ": " + e.Err.Error() // 提供面向排障的操作上下文
}

func (e *OpError) Unwrap() error {
    return e.Err // 让 errors.Is 与 errors.As 继续检查原因
}

func (e *OpError) Retryable() bool {
    return e.Retry // 返回领域层已经确认的分类结果
}

永久错误通常包括参数校验失败、认证或授权失败、违反不变量,以及调用方明确取消。可重试候选通常包括服务暂不可用、限流、短暂连接失败和可重放事务冲突。但“候选”不等于“立即重试”:例如请求已经被服务端接收,只是响应在网络中丢失,如果没有幂等键,再试一次可能制造重复订单。

Go原始错误、包装树、标准检查和领域分类静态关系图
图1:错误分类模型。原始错误经 %w 保留语义,errors.Is 与 errors.As 查询错误树,领域接口再给出可重试或永久结论;这是静态关系图,不是运行截图。

把分类器收敛成一个查询入口

分类规则分散在十几个调用点后,很快就会出现同一个错误在 A 服务里重试、在 B 服务里停止的情况。我更倾向于提供一个纯函数,让所有重试器从同一入口查询:

type Decision struct {
    Retry  bool
    Reason string
}

func Classify(err error) Decision {
    if err == nil {
        return Decision{Retry: false, Reason: "success"} // 成功不需要任何重试决策
    }

    if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
        return Decision{Retry: false, Reason: "context_done"} // 尊重调用方的取消与总预算
    }

    if errors.Is(err, ErrInvalidInput) || errors.Is(err, ErrForbidden) {
        return Decision{Retry: false, Reason: "permanent_domain_error"} // 请求不变时重试不会成功
    }

    var hinted RetryHint
    if errors.As(err, &hinted) {
        return Decision{Retry: hinted.Retryable(), Reason: "domain_hint"} // 优先采用领域层显式语义
    }

    var netErr net.Error
    if errors.As(err, &netErr) && netErr.Timeout() {
        return Decision{Retry: true, Reason: "timeout_candidate"} // 仍需由策略核对幂等与预算
    }

    return Decision{Retry: false, Reason: "unclassified"} // 未知错误保守停止,避免重复副作用
}

net.Error.Timeout() 只能说明超时属性,不保证业务操作没生效,因此我把它命名为候选。也不要再围绕 Temporary() 设计新协议:标准库文档已将该方法标为弃用,并指出多数临时网络错误本质上都是超时。

上下文超时也不应该被当前循环盲目重试。context 的取消信号意味着这条调用链的预算已经结束。上层调度器若要稍后创建一个新任务,可以有自己的策略,但当前操作应先返回。

让重试策略只负责预算、退避与幂等

分类器保持纯粹后,重试循环会简单很多。它只读取分类结果、总次数、退避间隔和上下文;真正的业务调用必须携带稳定幂等键:

func Retry(ctx context.Context, attempts int, call func(context.Context) error) error {
    var last error
    for i := 0; i 

实际工程里还应加入随机抖动、最大间隔、服务端返回的重试等待提示,以及全局并发限制。更关键的是,幂等键必须覆盖整个重试周期:创建订单、扣款、发消息等操作不能每次生成新键。若无法证明重放安全,即使分类器判断为瞬时故障,也应停止自动重试并转人工或补偿流程。

Go请求上下文、错误分类器、重试策略、幂等键和观测事件静态关系图
图2:重试策略职责边界。分类器只输出语义结论,策略读取预算和退避配置,业务操作由幂等键保护,并把决策写入观测事件;这是静态关系图。

未分类错误默认停下,并留下可观测证据

“未知错误默认重试”看起来可用性更高,实际却把风险推给了所有副作用。我的默认值是停止,同时记录结构化字段:操作名、错误类型、分类原因、是否幂等、尝试次数和剩余预算。这样新错误第一次出现时不会制造重试风暴,团队也能从观测数据补齐明确规则。

错误语义默认结论还要核对
参数、权限、业务不变量永久停止是否应提示调用方修正请求
context 取消或截止时间到期当前调用停止是否由更上层创建新任务
限流、暂不可用、连接超时可重试候选幂等、预算、服务端等待提示
未分类错误保守停止补充类型、测试和观测规则

如果一次操作同时返回多个原因,errors.Join 可以保留错误树,errors.Is 和 errors.As 仍能找到其中的匹配项。不过分类优先级必须明确:只要包含上下文取消或确认的永久错误,我通常就让“停止”优先,避免另一个瞬时错误把整体误判为可重试。

维护这条边界时要守住什么

把可观察语义写进 API 文档。如果函数承诺返回包装了 ErrInvalidInput 的错误,调用方就可以稳定使用 errors.Is;不要让它们依赖具体数据库、HTTP 客户端或云厂商 SDK 类型。

用矩阵测试分类器。至少覆盖直接错误、多层 %w、自定义 Unwrap、上下文取消、超时、未知错误和组合错误。测试的是语义结论,不是完整错误文本。

分类变化按兼容性变更处理。把一个错误从永久改为可重试,可能突然增加流量;反过来则可能降低成功率。规则上线时要观察重试次数、预算耗尽量、重复副作用拦截量和未分类错误数。

常见问题

HTTP 429 和 503 是否一定可重试?

它们通常是可重试候选,但仍要检查方法和业务是否幂等、响应是否提供等待时间、总预算是否允许。已经提交成功但响应丢失的写操作,没有幂等键就不能安全地自动重放。

是否应该给所有错误都实现 Retryable 方法?

不必。稳定的哨兵错误适合用 errors.Is,需要携带字段的领域错误再实现接口。让每个底层错误都实现它,反而会把传输层细节扩散到业务边界。

重试预算耗尽后返回什么?

返回一个包含“预算耗尽”上下文并包装最后错误的值,让上层既能知道策略已经停止,也能继续用 errors.Is 或 errors.As 检查根因。

参考资料:Go 1.13 错误处理:https://go.dev/blog/go1.13-errors;errors 标准库:https://pkg.go.dev/errors;context 标准库:https://pkg.go.dev/context;net.Error:https://pkg.go.dev/net#Error

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>