Files
Cyrene_v2/plugins/tools/io/search/errors.go
T
AskaEth 69a3b73777 feat(search): 原生 provider 层(searxng/智谱/阿里IQS/博查/火山/百度)+ 降级链(契约无改动)
主人定调「先把原生的做进插件,MCP 路线等后端 MCP 完善后再接」。
- 新包 plugins/tools/io/search/:一 provider 一文件 + 共享 http/提取器 + 归一化 Result;域名过滤/去重/排序**归一化后统一做一次**
- 配置 15 键:WEB_SEARCH_PROVIDER(默认 searxng)/_FALLBACK/_MAX_RESULTS + 五家 ×(API_KEY/BASE_URL);WEB_SEARCH_TIMEOUT 语义改为「**每次上游尝试**」(降级链才有意义)
- 硬语义:未注册/未配置的主 provider ⇒ **启动期报错**(不许静默回落);off ⇒ 不注册工具;缺 key 的家不进可用清单;降级链**如实标实际应答的 provider**(变异:拿主力冒充 ⇒ 测试红「provider = zhipu, want bocha」)
- 密钥绝不进日志/响应(有断言);错误分类(鉴权/限流/额度/网络)+ **空结果 ≠ 成功**(变异暴露过一个死分支⇒下移到 searchWith 修掉)
- searxng 真机 e2e 通过(provider=searxng 5 条);**其余五家无 key ⇒ 如实标未实测**;契约无改动(无新端点、工具 schema 不变)
- 注册表留好 B 档接入位(tavily/exa/firecrawl/serper/serpapi),并有「未登记⇒启动报错」的防回归断言
2026-10-05 22:38:20 +08:00

126 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package search
import (
"encoding/json"
"errors"
"net/http"
"strings"
)
// 本包的哨兵错误:把「上游为什么不行」如实分成几类
// (design/27 §1.4/§3:provider 报错与空结果必须如实区分)。
// 调用方一律用 errors.Is 判定。
var (
// ErrUnknownProvider 表示名字不在本期接入表里(写错了,或本期没做,如 tavily)。
ErrUnknownProvider = errors.New("search: unknown provider")
// ErrNotConfigured 表示这家 provider 没配齐(缺 key / 缺 base URL)。
ErrNotConfigured = errors.New("search: provider not configured")
// ErrUnauthorized 表示上游拒了凭据(401/403)。
ErrUnauthorized = errors.New("search: upstream rejected credentials")
// ErrRateLimited 表示被上游限流(429)。
ErrRateLimited = errors.New("search: upstream rate limited")
// ErrQuotaExceeded 表示额度用尽/欠费/未开通(含 403 + 业务码)。
ErrQuotaExceeded = errors.New("search: upstream quota exceeded")
// ErrNetwork 表示网络层失败(连不上、DNS、超时)——它**是**「该家不可用」的一种。
ErrNetwork = errors.New("search: network error")
// ErrUpstream 表示上游 5xx 或其他无法进一步分类的失败状态。
ErrUpstream = errors.New("search: upstream error")
// ErrResponseTooLarge 表示上游响应超过本仓的读取上限(不静默截断成半截 JSON)。
ErrResponseTooLarge = errors.New("search: response exceeds size limit")
// ErrBadResponse 表示响应不是预期结构(JSON 解不开,或按 spec 压根找不到结果数组)。
ErrBadResponse = errors.New("search: malformed upstream response")
// ErrNoResults 表示上游正常应答但**没有任何结果**。
//
// 它是「空」不是「成功」,也**不是**「该家不可用」:降级链遇到它要停下并如实
// 报「查到了 0 条」,而不是前进一格假装换一家就有了(design/27 §1.4/§3)。
ErrNoResults = errors.New("search: no results")
)
// Unavailable 报告一个错误是否属于「该家不可用」——只有这类错误才让降级链前进一格
// (design/27 §3:无 key / 网络失败 / 额度错误…)。
//
// 空结果(ErrNoResults)与上下文取消/超时**不在**其列:前者是「空」不是「不可用」,
// 后者是预算已经花光,换一家也不会有时间。
func Unavailable(err error) bool {
switch {
case err == nil:
return false
case errors.Is(err, ErrNoResults):
return false
case errors.Is(err, ErrNotConfigured),
errors.Is(err, ErrUnauthorized),
errors.Is(err, ErrRateLimited),
errors.Is(err, ErrQuotaExceeded),
errors.Is(err, ErrNetwork),
errors.Is(err, ErrUpstream),
errors.Is(err, ErrResponseTooLarge),
errors.Is(err, ErrBadResponse):
return true
default:
return false
}
}
// classifyStatus 把上游的失败状态码归成本仓的哨兵错误。
// 先看业务错误码(各家官方文档确证的那几个,见 upstreamCodeClass),再看 HTTP 状态。
func classifyStatus(status int, code string) error {
if e, ok := upstreamCodeClass(code); ok {
return e
}
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
// 403 既可能是鉴权也可能是额度,业务错误码(若上游给了)已在上面先行判定。
return ErrUnauthorized
case status == http.StatusPaymentRequired:
return ErrQuotaExceeded
case status == http.StatusTooManyRequests:
return ErrRateLimited
default:
// 5xx 与其余 4xx 一律归 ErrUpstream(工具侧只关心「不可用 ⇒ 前进一格」)。
return ErrUpstream
}
}
// upstreamCodeClass 把各家**官方文档确证**的业务错误码归类到哨兵错误(大小写不敏感)。
//
// 来源:阿里云 IQS 错误码表(help.aliyun.com/document_detail/2857020.html;
// DevDocs/reference/search-providers/15-阿里云-IQS.md 的「错误码」节)。
// 只收录确证过的条目 —— 未确证的错误码一律不猜。
func upstreamCodeClass(code string) (error, bool) {
switch strings.ToLower(strings.TrimSpace(code)) {
case "retrieval.throttling.user", "retrieval.testuserqueryperdayexceeded":
return ErrRateLimited, true
case "retrieval.arrears", "retrieval.notactivate", "retrieval.testuserperiodexpired":
return ErrQuotaExceeded, true
case "retrieval.notauthorised":
return ErrUnauthorized, true
default:
return nil, false
}
}
// codePaths 是上游错误体里「业务错误码」的候选位置(点分路径,按序取第一个非空字符串)。
// 各家形状不一,这里给的是容错候选;取不到就当没有错误码(按状态码分类)。
func codePaths() []string {
return []string{"code", "error.code", "error_code", "ErrorCode", "ResponseMetadata.Error.Code"}
}
// upstreamCode 尽力从错误响应体里取出业务错误码。取不到(不是 JSON、没有该字段)返回空串。
func upstreamCode(data []byte) string {
if len(data) == 0 {
return ""
}
var root any
if err := json.Unmarshal(data, &root); err != nil {
return ""
}
for _, p := range codePaths() {
if v, ok := resolvePath(root, p); ok {
if s := scalarString(v); s != "" {
return s
}
}
}
return ""
}