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 "" }