Files
Cyrene_v2/plugins/tools/io/search/errors.go
T
AskaEth 25aa50e532
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): 智谱 provider 按官方 schema 加固(请求补确证字段 + 错误码分档)
官方文档逐页确证后落地的四件(分册 11 的「官方 schema 确证(2026-10-07)」):
- 请求补 search_intent:false(官方标 required,缺发可能触发 1213)与
  content_size:medium;count 仅 1–50 透传,越界不发该字段;
  search_query >70 rune 直接报错且**不发请求**;
- 响应按官方收窄:摘要只认 snippet/summary/abstract 候选(官方响应**没有**
  snippet),publish_date 入 Published;
- 错误码分档:1000/1001/1003⇒unauthorized、1113⇒quota_exceeded、
  1302/1305⇒rate_limited。🔴 1113 与 1302/1305 **同为 HTTP 429** ⇒ 只看状态码
  分不开「该充值」与「该退避」,必须读 error.code(码同时进日志文案);
- media/icon/refer 当前一律不投影(Result 没有「来源站点」位;要投影应给
  Result 加字段 + 同步契约,不许塞进 Snippet —— 否则摘要位会被站点名恒填满)。

另修两处过期注释(providers_test.go 仍写「只发 search_engine/search_query」)。

验收(主会话复跑 + 亲自变异):go vet 绿;search 包与调用方 io 包全绿;
我用 -overlay 删掉 1113⇒quota_exceeded 映射 ⇒ TestZhipuErrorCodeMapping 变红
(欠费被误报成限流、探针码 rate_limited)。

⚠️ 候选键容错暂未删除:官方已排除的键保留到**真机实测通过后**再收窄(现无 key)。
⚠️ 真机未跑:仍只打 httptest,官方真实响应形态未实测。
2026-10-07 09:59:24 +08:00

176 lines
7.7 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"
"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 分表」**,不能靠码值碰运气。
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 ""
}