toolfail

package
v0.18.0 Latest Latest
Warning

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

Go to latest
Published: Nov 5, 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