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 ¶
- Variables
- func Retryable(err error) bool
- type Class
- type Failure
- func (f *Failure) Error() string
- func (f *Failure) Guidance() string
- func (f *Failure) Idempotent() *Failure
- func (f *Failure) Remediation() string
- func (f *Failure) Retryable() bool
- func (f *Failure) SafeToResubmit() bool
- func (f *Failure) Unwrap() error
- func (f *Failure) WithCause(err error) *Failure
- func (f *Failure) WithIdempotency(id Idempotency) *Failure
- func (f *Failure) WithRemediation(r string) *Failure
- func (f *Failure) WithRetryAfter(d time.Duration) *Failure
- type Idempotency
- type Policy
- type RetryableReporter
Constants ¶
This section is empty.
Variables ¶
var ( ErrTransient = errors.New("瞬时故障") ErrPermanent = errors.New("永久性失败") ErrAuthentication = errors.New("身份凭证无效") ErrNotFound = errors.New("目标不存在") ErrPermissionDenied = errors.New("权限不足") ErrInvalidArgument = errors.New("参数非法") ErrRateLimited = errors.New("被限流") )
哨兵错误集:工具侧可原样返回本包的哨兵,由 Classify 归类。
为什么给哨兵而不是让工具自造错误:归类判据必须单源。若允许每个工具各自 拼字符串给上游猜,判定就会退化成「看文案里有没有某个词」,而文案会被改写。
Functions ¶
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 Classify ¶
Classify 归类。四级判据,**顺序不可调换**:
- 链上已有非 nil 的 *Failure → 取它的类别。显式分类是最具体的一份判定: 它带着类别 + 码 + 等待时长,是产出它的那一层已经做完判断的结果。
- 错误自报(RetryableReporter)→ 报「可重试」给 ClassSelfReported, 报「不可重试」给 ClassUnknown。两包各答一套的漂移正出在第 2 级缺席时, 故它必须排在哨兵之前:自报是这一次的实情,哨兵只是某个通用含义。
- 哨兵集合逐个 errors.Is(工具侧直接返回本包哨兵的路径)。
- 都判不出来 → 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 ClassifyAs ¶
ClassifyAs 按调用方给定的类别归类:把 err 包成 *Failure 后照常返回。
用于工具侧已有自有错误体系的场景——它在**知道类别的那一刻**调一次, 之后整条链路都可由 Classify/As 直接捡走,无需本包去猜。 err 为 nil 时仍返回 ClassUnknown(没有失败就没有类别可言)。
func (*Failure) Guidance ¶
Guidance 把分类结论渲染成一行面向模型的指引:处置指引 + 重试/重发/人工三维结论。
它是 Remediation 的**确定消费方**:判据建了没人读等于没建。本函数把 「发生了什么」(Class/Message) 转成「你下一步该干什么」,是模型唯一需要的形态。
为什么编排层不自己拼:拼法一旦散落到各调用点,同一类失败会在不同环节给模型 不同指令,而模型对指令措辞的敏感度远高于人对它的敏感度——措辞漂移会直接变成 行为漂移。
func (*Failure) Remediation ¶
Remediation 该次失败面向模型的处置指引:优先取 Failure 自带,否则取类别缺省。
单次覆盖存在的理由:同一类别里不同工具的下一步往往不同 (同一个 invalid_argument,改哪个字段取决于是哪个参数非法)。
func (*Failure) SafeToResubmit ¶
SafeToResubmit 原样重发这次调用是否安全(不会造成重复副作用)。
判定顺序:宿主逐次表态优先,未表态才取类别缺省。
与 Retryable 分开是因为两者可以相反,且相反时最危险:超时值得重试, 但服务端可能已经执行过,原样重发就会把一次调用变成两次副作用。 只看 Retryable 的调用方必然在这两种误判中选一个。
func (*Failure) WithIdempotency ¶
func (f *Failure) WithIdempotency(id Idempotency) *Failure
WithIdempotency 声明本次调用的幂等性(覆盖类别缺省),返回自身以便链式装配。
func (*Failure) WithRemediation ¶
WithRemediation 附上该次失败专有的模型指引(覆盖类别缺省),返回自身便于链式装配。
type Idempotency ¶
type Idempotency int
Idempotency 幂等性声明:这次调用**原样重发**会不会造成重复副作用。
为什么要三态而不是 bool:宿主对「我这个工具幂等吗」的确是有知识的(只读查询 幂等、创建类不幂等),但**大多数失败它并没有专门想过**。用 bool 的话,"没想过" 与 "确实不幂等" 会被压成同一个 false,缺省口径就被静默改成了「一律不安全」; 用三态则缺省仍是「按类别默认」,只有显式表态才覆盖。
const ( // IdempotencyUnset 宿主未表态(缺省):按 PolicyOf(Class).SafeToResubmit 取类别默认。 IdempotencyUnset Idempotency = iota // IdempotencyYes 明确幂等 / 本次调用未被受理:原样重发安全。 IdempotencyYes // IdempotencyNo 明确有副作用 / 本次调用可能已生效:原样重发不安全。 IdempotencyNo )
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 单个失败类别的重试处置策略。
type RetryableReporter ¶
type RetryableReporter interface {
// Retryable 这次失败原样重试是否有意义。
//
// 实现约定:接收者可能为 nil(错误链上挂着 typed-nil 指针),此时必须返回
// false 而非解引用——本包对链上每一环都直接调方法,不替你防 panic。
Retryable() bool
}
RetryableReporter 错误**自报**的「可重试」结论。
为什么需要它:有些失败的可重试性只有产出它的那一层知道。典型是执行门禁—— 它知道这次拒绝已经顺手把工具激活了,故重试必成,而「重试会成」这件事在 错误链上除了这一个方法之外没有任何痕迹。若本包不接这根线,Classify 只能把它 落到 ClassUnknown(不可重试),通用退避循环于是把一次**可恢复**的失败当成 永久失败,既该重试的不重试,还把门禁那次激活的收益一并作废。 两边各答一套必然漂移——本接口就是那根接线,判据本来就在,只是没人去问。
命名说明:本来想直接叫 Retryable,但同包已有同名**函数** Retryable(err), 同包内类型与函数不能同名;保留 Reporter 是为了点明它是「错误侧的报告者」, 不是本包的判定入口(判定入口仍是 Retryable)。