Help us shape the future of Go! Take the 2026 Go Developer Survey

toolfail

package
v0.15.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 23, 2026 License: MIT Imports: 4 Imported by: 0

Documentation

Overview

Package toolfail 工具调用的失败分类契约:**这个失败值不值得重试**的机械判据。

为什么需要这个包

编排引擎要自动退避重试,第一件事是判断「这次失败再试一次有没有可能不同」。 本仓此前只有 permgate 的「解析失败即拒」——那是**准入**语义(这份输入能不能进), 不是**恢复**语义(失败后该重试、退避还是叫停)。两层缺一不可,且不能互相顶替: 用准入语义去驱动重试,会把「本来就该拒」的输入反复重试到超时。

之所以单独立一个包而不是散落在各调用点:退避判定是**跨宿主一致的语义**, 而「某次调用到底失败在哪一类」取决于具体工具,由宿主/工具侧产出。 两边各判一套必然漂移,漂移的后果是重试策略静默错乱(对永久失败死循环重试, 或对瞬时失败一次就放弃)。

关键取舍一:不可分类 = 不可重试(保守缺省)

无法归类的错误归 ClassUnknown,且**不可重试**。

理由:退避重试的代价是不对称的——重试一个本来会成功的调用,只是多花一点时间; 重试一个本来不会成功的调用,是把一次失败放大成 N 次,还可能对有副作用的工具 重复施加副作用。判据缺失时,两种误判的成本不在一个量级,故取保守的一侧。 宿主若认为某类未知错误可重试,应显式归类,而不是让本包替它乐观。

关键取舍二:策略是数据不是 if 链

每个类的可重试性/退避/延迟提示/人工介入/重发安全/处置指引写进 PolicyOf 的表里, 而不是散成一串 if。上层只需问「PolicyOf(c)」,不必知道「限流可重试但要退避、 鉴权不可重试但要人工」这些并列事实——策略表与 contract 契约测试同源, 任何人改表都会被契约测试挡住。

关键取舍三:重发安全与重试价值是两件事

本包回答的是两个正交问题:

① 重试有没有收益?            → Retryable / Backoff
② 原样重发安不安全(会不会重复施加副作用)? → SafeToResubmit

两问的答案可以相反,且相反的组合最危险:**「值得重试」不等于「可以重发」**。 超时正是典型——它多半值得重试,可服务端那边请求**可能已经执行完毕**, 原样重发就可能把一次调用变成两次副作用。原有的单一维度表达不出这件事, 调用方只能要么盲目重试(制造重复副作用),要么因噎废食放弃退避(失去可恢复性)。

之所以归本包而不是宿主:判定「重发是否安全」的**默认口径**必须是全局一致的, 各宿主各判一套就会漂移;而**具体工具是否幂等**是宿主的知识,由 Failure 逐次 声明覆盖(Idempotency),本包只提供缺省与查询。

关键取舍四:失败必须能对模型说话

Remediation 是**面向模型**的处置指引,与 Message 刻意分开:Message 是给人看的 (进日志、进工单),会被改写、会含具体数值,不适合进提示词;而模型需要知道的是 「你下一步该干什么」,不是「发生了什么」。

它与 Guidance 必须成对存在:一张建好却没有消费方的表等于散文——判据建了没人读, 等于没建。故本包同时提供 Guidance 作为**确定的消费方**,把 Remediation 与 重试/重发/人工三维结论渲染成一行给模型。

与 mcpkit/mcp.ErrorKind 的分工

mcpkit 的 ErrorKind 判的是**协议层事实**:连不上 / 协议错 / 工具不存在 / 参数错。 它锚在 JSON-RPC 错误码与 net.Error 上,回答「这次调用在传输层出了什么事」。

本包判的是**处置层语义**:这次失败该不该重试、能不能原样重发、要不要人工、 模型下一步该干什么。它锚在失败**类别**上,回答「拿到这个失败该怎么处置」。

两层是互补的,不是重复的:mcpkit 的四类完全可以映射到本包的类别 (unreachable→transient、tool_missing→not_found、invalid_args→invalid_argument), 而本包多出的六个类别(permanent / authentication / permission_denied / rate_limited / self_reported / unknown)与三个维度(重发安全、人工介入、 面向模型的指引)是协议层给不出来的——协议层不知道「这次调用是不是只读的」。

边界

本包只做分类与策略**判定**,不做重试调度(何时重试、重试几次、退避曲线是编排层的事), 也不认识任何具体工具——工具名、参数、返回结构全在宿主侧。

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrTransient        = errors.New("瞬时故障")
	ErrPermanent        = errors.New("永久性失败")
	ErrAuthentication   = errors.New("身份凭证无效")
	ErrNotFound         = errors.New("目标不存在")
	ErrPermissionDenied = errors.New("权限不足")
	ErrInvalidArgument  = errors.New("参数非法")
	ErrRateLimited      = errors.New("被限流")
)

哨兵错误集:工具侧可原样返回本包的哨兵,由 Classify 归类。

为什么给哨兵而不是让工具自造错误:归类判据必须单源。若允许每个工具各自 拼字符串给上游猜,判定就会退化成「看文案里有没有某个词」,而文案会被改写。

Functions

func Retryable

func Retryable(err error) bool

Retryable 判断任意错误是否值得原样重试:先归类再查策略表。

这是上层最常用的入口——它不需要知道错误体系长什么样。

自报错误在这里与在错误自身那里必然**同答**:ClassSelfReported 的策略表项是 可重试、自报不可重试落到 ClassUnknown(亦不可重试),两头都不翻。上层只认这一个 入口就够,不必回头去 errors.As 找 RetryableReporter——那正是两处各判一套的开始。

Types

type Class

type Class string

Class 失败类别(机读值,取值见下常量集)。

用字符串而非整数枚举:类别要随事件载荷落日志、随调用链跨进程传递, 数字序一旦被无意依赖(排序、相减)就会变成隐藏契约。

const (
	// ClassTransient 瞬时故障:网络抖动、连接重置、超时、服务端临时不可用。
	// 重试有意义——同样的调用稍后可能成功。
	ClassTransient Class = "transient"

	// ClassPermanent 永久性失败:同样的输入再调一次仍然失败(能力不支持、
	// 请求与服务端能力不匹配等)。重试是纯浪费。
	ClassPermanent Class = "permanent"

	// ClassAuthentication 身份凭证无效/过期。重试不会让过期凭证变新,
	// 除非期间有别的环节刷新了它——那属于编排层决策,不属于「原样重试」。
	ClassAuthentication Class = "authentication"

	// ClassNotFound 目标不存在。重试不创造对象。
	ClassNotFound Class = "not_found"

	// ClassPermissionDenied 权限不足。重试不提升权限,需要有人放开权限。
	ClassPermissionDenied Class = "permission_denied"

	// ClassInvalidArgument 参数非法。修参数才有用,原样重试必然复现。
	ClassInvalidArgument Class = "invalid_argument"

	// ClassRateLimited 被限流。可重试,且服务端通常会给出建议等待时长。
	ClassRateLimited Class = "rate_limited"

	// ClassSelfReported 错误**自己**声明了「原样重试有收益」,但本包不认得它属于哪一类。
	//
	// 为什么单立一类而不并入 ClassTransient:可重试 ≠ 瞬时。典型如执行门禁——
	// 工具在目录内、schema 尚未取回时,它把这个拒绝标成可重试(并已顺手激活该工具,
	// 重试必成),可这类失败与网络抖动的成因、处置手段都不一样。挤进 transient
	// 就把调用方可能想要的判断(退避多久、失败源在哪一段)一并抹掉了。
	//
	// 本类只承载「可重试」这一位信息,不承载成因:要连成因一起表达,走
	// ClassifyAs 显式归类成别的类。
	ClassSelfReported Class = "self_reported"

	// ClassUnknown 无法归类。**不可重试**(见包注释「关键取舍一」)。
	ClassUnknown Class = "unknown"
)

func AllClasses

func AllClasses() []Class

AllClasses 全类别集合(新判据:每类都必须落在表里)。

func Classify

func Classify(err error) Class

Classify 归类。四级判据,**顺序不可调换**:

  1. 链上已有非 nil 的 *Failure → 取它的类别。显式分类是最具体的一份判定: 它带着类别 + 码 + 等待时长,是产出它的那一层已经做完判断的结果。
  2. 错误自报(RetryableReporter)→ 报「可重试」给 ClassSelfReported, 报「不可重试」给 ClassUnknown。两包各答一套的漂移正出在第 2 级缺席时, 故它必须排在哨兵之前:自报是这一次的实情,哨兵只是某个通用含义。
  3. 哨兵集合逐个 errors.Is(工具侧直接返回本包哨兵的路径)。
  4. 都判不出来 → ClassUnknown,不可重试(见包注释「关键取舍一」)。

第 2 级报「不可重试」时直接给 ClassUnknown 而**不再**走哨兵:若让哨兵把结论翻成 可重试,就等于本包亲口否定了错误自己的报告,两个入口(错误自报 / Retryable(err)) 会对同一个错误给出相反答案——那正是这条判据要消灭的漂移,换个方向再来一遍而已。

type Failure

type Failure struct {
	// Class 失败类别,决定重试策略。
	Class Class

	// Code 稳定机读码(如 "rate_limited")。跨版本稳定,用于聚合与判定,
	// 不得写入具体数值、标识或耗时等每次都变的内容。
	Code string

	// Message 人读文案(进日志与上报,不参与判定)。
	Message string

	// RemediationOverride 该次失败专有的模型指引;为空时取 PolicyOf(Class).Remediation。
	//
	// 为什么允许逐次覆盖:处置指引的下一步动作常常取决于**具体是哪个**参数非法、
	// 哪个标识没找到,逐次覆盖比让模型去猜更快也更准。
	RemediationOverride string

	// Idempotency 宿主对本次调用的幂等性表态;未表态时按类别默认取值。
	//
	// 为什么要宿主逐次声明:同一个类别下不同工具的幂等性可以完全相反
	//(同样是 transient,一个只读查询重发毫无风险,另一个创建接口重发就重复了)。
	// 类别只能给缺省,逐次表态才是准确的。
	Idempotency Idempotency

	// RetryAfter 服务端建议的等待时长;0 表示无此提示。
	// 仅在 PolicyOf(Class).SurfaceDelay 为真时有意义。
	RetryAfter time.Duration

	// Err 可选根因(底层错误)。保留供 errors.Is/As 判定与排查,
	// 不参与策略判定——策略只看 Class。
	Err error
}

Failure 结构化失败:类别 + 稳定机读码 + 人读文案 + 可选等待时长 + 可选根因。

为什么三者(Class/Code/Message)都要留:

  • Class 给编排**策略**(问 PolicyOf);
  • Code 给上报与统计**聚合**(文案会变,码不能跟着变);
  • Message 给人**定位**。

只留文案是最常见的做法,但文案是给模型看的、会被改写,按文案聚合统计必然失真。

func As

func As(err error) (*Failure, bool)

As 从错误链上取出 *Failure(取出即可读类别、码与等待时长)。

func ClassifyAs

func ClassifyAs(c Class, err error) *Failure

ClassifyAs 按调用方给定的类别归类:把 err 包成 *Failure 后照常返回。

用于工具侧已有自有错误体系的场景——它在**知道类别的那一刻**调一次, 之后整条链路都可由 Classify/As 直接捡走,无需本包去猜。 err 为 nil 时仍返回 ClassUnknown(没有失败就没有类别可言)。

func New

func New(c Class, code, msg string) *Failure

New 构造失败(类别为空按 ClassUnknown 处理;码为空时取类别值,保证机读码恒非空)。

func (*Failure) Error

func (f *Failure) Error() string

Error 实现 error。nil 接收者返回缺省串,避免把 nil *Failure 传进日志链路时 panic。

func (*Failure) Guidance

func (f *Failure) Guidance() string

Guidance 把分类结论渲染成一行面向模型的指引:处置指引 + 重试/重发/人工三维结论。

它是 Remediation 的**确定消费方**:判据建了没人读等于没建。本函数把 「发生了什么」(Class/Message) 转成「你下一步该干什么」,是模型唯一需要的形态。

为什么编排层不自己拼:拼法一旦散落到各调用点,同一类失败会在不同环节给模型 不同指令,而模型对指令措辞的敏感度远高于人对它的敏感度——措辞漂移会直接变成 行为漂移。

func (*Failure) Idempotent

func (f *Failure) Idempotent() *Failure

Idempotent 便捷构造:声明原样重发安全。

func (*Failure) Remediation

func (f *Failure) Remediation() string

Remediation 该次失败面向模型的处置指引:优先取 Failure 自带,否则取类别缺省。

单次覆盖存在的理由:同一类别里不同工具的下一步往往不同 (同一个 invalid_argument,改哪个字段取决于是哪个参数非法)。

func (*Failure) Retryable

func (f *Failure) Retryable() bool

Retryable 是否值得原样重试(策略查询的薄封装,上层不必先拿 Class)。

func (*Failure) SafeToResubmit

func (f *Failure) SafeToResubmit() bool

SafeToResubmit 原样重发这次调用是否安全(不会造成重复副作用)。

判定顺序:宿主逐次表态优先,未表态才取类别缺省。

与 Retryable 分开是因为两者可以相反,且相反时最危险:超时值得重试, 但服务端可能已经执行过,原样重发就会把一次调用变成两次副作用。 只看 Retryable 的调用方必然在这两种误判中选一个。

func (*Failure) Unwrap

func (f *Failure) Unwrap() error

Unwrap 交出根因,保住 errors.Is/As 判定链。

func (*Failure) WithCause

func (f *Failure) WithCause(err error) *Failure

WithCause 附上根因,返回自身以便链式装配。

func (*Failure) WithIdempotency

func (f *Failure) WithIdempotency(id Idempotency) *Failure

WithIdempotency 声明本次调用的幂等性(覆盖类别缺省),返回自身以便链式装配。

func (*Failure) WithRemediation

func (f *Failure) WithRemediation(r string) *Failure

WithRemediation 附上该次失败专有的模型指引(覆盖类别缺省),返回自身便于链式装配。

func (*Failure) WithRetryAfter

func (f *Failure) WithRetryAfter(d time.Duration) *Failure

WithRetryAfter 附上建议等待时长,返回自身以便链式装配。

type Idempotency

type Idempotency int

Idempotency 幂等性声明:这次调用**原样重发**会不会造成重复副作用。

为什么要三态而不是 bool:宿主对「我这个工具幂等吗」的确是有知识的(只读查询 幂等、创建类不幂等),但**大多数失败它并没有专门想过**。用 bool 的话,"没想过" 与 "确实不幂等" 会被压成同一个 false,缺省口径就被静默改成了「一律不安全」; 用三态则缺省仍是「按类别默认」,只有显式表态才覆盖。

const (
	// IdempotencyUnset 宿主未表态(缺省):按 PolicyOf(Class).SafeToResubmit 取类别默认。
	IdempotencyUnset Idempotency = iota

	// IdempotencyYes 明确幂等 / 本次调用未被受理:原样重发安全。
	IdempotencyYes

	// IdempotencyNo 明确有副作用 / 本次调用可能已生效:原样重发不安全。
	IdempotencyNo
)

func (Idempotency) String

func (i Idempotency) String() string

String 机读名(进日志与事件载荷)。

type Policy

type Policy struct {
	// Class 该策略对应的类别。
	Class Class

	// Retryable 原样重试是否有意义。
	Retryable bool

	// Backoff 是否建议退避后重试(重试有收益但不该立刻打)。
	//
	// 与 Retryable 分开是因为「立刻重试」与「退避重试」是两种不同代价:
	// 限流立刻重试只会立刻再被限流,把一次退避能吸收的拥塞放大成重试风暴。
	Backoff bool

	// SurfaceDelay 是否应把服务端给的等待时长向上层暴露(限流类才有此信息)。
	SurfaceDelay bool

	// NeedsOperator 是否需要人工介入(改凭证/放权限,机器自己无能为力)。
	NeedsOperator bool

	// SafeToResubmit 该类失败**默认**能否原样重发(不产生重复副作用)。
	//
	// 与 Retryable 正交,组合起来才完整表达自动重试的决策:
	//   - 两者皆真  → 直接重发;
	//   - 可重试但不可重发 → 重发前必须确认上次是否已生效(典型的超时类);
	//   - 不可重试 → 换处置路径(改参数 / 找人工 / 收口)。
	//
	// 取值一律保守:只有**能证明服务端未受理本次调用**的类别才置真
	// (限流即如此——请求根本没进业务逻辑)。超时类不给真:超时的定义就是
	// 「不知道对方执行到哪」,把它当成安全重发是把一次调用放大成两次。
	SafeToResubmit bool

	// Remediation 面向**模型**的处置说明(回答「这是什么情况、我该改什么」)。
	//
	// 与 Failure.Message 刻意分开:Message 是给人看的(进日志与上报),
	// 会被改写、含具体数值;提示词里要的是可执行的指引,不是现场描述。
	// 取值不得包含具体标识、数值与业务词面——它会原样进入提示词。
	//
	// **不得自带重试结论**(不要写「重试」「等待 N 秒」「需要人工」):
	// 那是 Guidance 的机械判定,写在这里会与之重复,个别类别还会直接矛盾
	//(自报类文案写「可以直接重试」,而其 SafeToResubmit 为假,Guidance 会说
	//「先确认上一次是否已生效」——同一段提示词里两个相反的指令,模型无从取舍)。
	// 该约束由 TestRemediationCarriesNoRetryVerdict 钉住。
	Remediation string
}

Policy 单个失败类别的重试处置策略。

func PolicyOf

func PolicyOf(c Class) Policy

PolicyOf 取某类别的策略。未知类别(含零值)一律按 ClassUnknown 处理, 保持「查不到就不可重试」的单向失败——策略查询不该是第二个不确定来源。

type RetryableReporter

type RetryableReporter interface {
	// Retryable 这次失败原样重试是否有意义。
	//
	// 实现约定:接收者可能为 nil(错误链上挂着 typed-nil 指针),此时必须返回
	// false 而非解引用——本包对链上每一环都直接调方法,不替你防 panic。
	Retryable() bool
}

RetryableReporter 错误**自报**的「可重试」结论。

为什么需要它:有些失败的可重试性只有产出它的那一层知道。典型是执行门禁—— 它知道这次拒绝已经顺手把工具激活了,故重试必成,而「重试会成」这件事在 错误链上除了这一个方法之外没有任何痕迹。若本包不接这根线,Classify 只能把它 落到 ClassUnknown(不可重试),通用退避循环于是把一次**可恢复**的失败当成 永久失败,既该重试的不重试,还把门禁那次激活的收益一并作废。 两边各答一套必然漂移——本接口就是那根接线,判据本来就在,只是没人去问。

命名说明:本来想直接叫 Retryable,但同包已有同名**函数** Retryable(err), 同包内类型与函数不能同名;保留 Reporter 是为了点明它是「错误侧的报告者」, 不是本包的判定入口(判定入口仍是 Retryable)。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL