接入位表六家扩到八家,端点形状与错误码一字未改(契约 v0.87,仅两处名单)。
- tencent_wsa:POST {base}/SearchPro + Authorization Bearer,零签名。
两条官方口径决定实现:HTTP 状态码恒 200(判失败只能读 Response.Error.Code);
Pages 是「JSON 字符串数组」需二次解析。错误码按官方表分类
(UnauthorizedOperation/AuthFailure.* = 凭据、ResourceUnavailable = 欠费、
RequestLimitExceeded* = 限流),文案只带 code 不带上游 Message。
- metaso:POST {base}/api/v1/search,响应 schema 仅第三方来源且互相打架,
实现走宽松解析(数组候选与字段名回退),逐处标「未实测」。
- 零新 env 键:两家只走 $DATA_DIR/config/plugins/web-search.json,
.env / .env.example 未动;组合根 websearch_providers.go 零功能改动。
- 契约 v0.87:values.provider 合法闭集与 plugin.providers 候选各加两条,
计数不变(14 + 25 + 40 = 79)。
181 lines
8.2 KiB
Go
181 lines
8.2 KiB
Go
package search
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"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 状态。
|
||
//
|
||
// 业务错误码非空时,返回值会带上 `(code=…)`:**只看 HTTP 状态分不开同一状态下的不同含义**
|
||
// —— 智谱把「欠费」与「限流」都放在 HTTP 429(1113 vs 1302/1305),码是唯一判据;这段文案
|
||
// 会被工具层记进日志,运维据此知道该充值还是该退避(分册 11 错误码节)。`errors.Is` 仍能
|
||
// 命中对应哨兵(用 %w 包装)。
|
||
//
|
||
// ⚠️ 只带 **code**、不带上游 message:message 是任意上游文本(可能回显请求信息),而 `do` 的
|
||
// 契约是「错误体不入错误文案」(见 http.go),凭据更不许出现在错误/日志里。
|
||
func classifyStatus(status int, code string) error {
|
||
sentinel := classifyStatusSentinel(status, code)
|
||
if c := strings.TrimSpace(code); c != "" {
|
||
return fmt.Errorf("%w (code=%s)", sentinel, c)
|
||
}
|
||
return sentinel
|
||
}
|
||
|
||
// classifyStatusSentinel 是「业务码优先、再看状态码」的原判定(不带码回显)。
|
||
func classifyStatusSentinel(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:
|
||
// 429 有两种含义(欠费 / 限流):表里没收录的码按**保守档**当限流处理。
|
||
return ErrRateLimited
|
||
default:
|
||
// 5xx 与其余 4xx 一律归 ErrUpstream(工具侧只关心「不可用 ⇒ 前进一格」)。
|
||
return ErrUpstream
|
||
}
|
||
}
|
||
|
||
// upstreamCodeClass 把各家**官方文档确证**的业务错误码归类到哨兵错误(大小写不敏感)。
|
||
//
|
||
// 来源:① 阿里云 IQS 错误码表(help.aliyun.com/document_detail/2857020.html;
|
||
// DevDocs/reference/search-providers/15-阿里云-IQS.md 的「错误码」节);② 智谱 BigModel
|
||
// 平台通用错误码表(见 zhipuCodeClass)。
|
||
// 只收录确证过的条目 —— 未确证的错误码一律不猜。
|
||
func upstreamCodeClass(code string) (error, bool) {
|
||
if e, ok := zhipuCodeClass(code); ok {
|
||
return e, true
|
||
}
|
||
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
|
||
}
|
||
}
|
||
|
||
// zhipuCodeClass 是智谱 BigModel 的**平台通用**错误码表(分册 11「官方 schema 确证」③
|
||
// 逐码对应官方原文,适用 `open.bigmodel.cn/api/paas/v4/*`):
|
||
//
|
||
// 1000 / 1001 / 1003(HTTP 401)⇒ 身份验证失败 / 未收到凭据 / 凭据过期 ⇒ unauthorized
|
||
// 1113(HTTP 429) ⇒ 账户欠费 ⇒ quota_exceeded
|
||
// 1302 / 1305(HTTP 429) ⇒ 账户限流 / 平台过载 ⇒ rate_limited
|
||
//
|
||
// 🔴 1113 与 1302/1305 **同为 HTTP 429** ⇒ 不读 code 就分不开「该充值」与「该退避」。
|
||
// 搜索专用码 1701(并发达上限)/1702/1703 只登记、不在此映射:官方未给它们的 HTTP 状态码,
|
||
// 保守交给状态码兜底(429 未收录码 ⇒ rate_limited,其余 4xx/5xx ⇒ upstream)。
|
||
//
|
||
// ⚠️ 作用域:这些是**裸数字码**,而本函数被所有 provider 共用。走本函数的家里只有智谱用数字
|
||
// 业务码(阿里云是 `Retrieval.*` 点分码)⇒ 暂按码值归类。**若将来有第二家走本函数的家也用
|
||
// 数字码,必须改成「按 provider 分表」**,不能靠码值碰运气。
|
||
//
|
||
// 已经有第二家用数字码的家族:秘塔(`errCode=2005` 等)与腾讯云(大写点分码)。它们**不经过
|
||
// 本函数**——那两家的错误随 **HTTP 200** 返回,classifyStatus(只处理非 2xx)根本看不到,
|
||
// 因此各自在 provider 文件里维护自己的码表(`metasoCodeClass` / `tencentCodeClass`)。
|
||
// 这正是「按 provider 分表」的落地形态:**共用表保持最小、各家码表就近**。
|
||
func zhipuCodeClass(code string) (error, bool) {
|
||
switch strings.TrimSpace(code) {
|
||
case "1000", "1001", "1003":
|
||
return ErrUnauthorized, true
|
||
case "1113":
|
||
return ErrQuotaExceeded, true
|
||
case "1302", "1305":
|
||
return ErrRateLimited, 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 ""
|
||
}
|