M48 复审的中危 finding:error.type 里的散文经 trace.UpstreamError 原样出镜,
因为守卫加在调用方(M46 的 model/voice 两处),构造函数自己不套守卫 ——
任何新调用点默认不安全,httpclient / vision / embedding / weixin 四条出口一直漏。
- trace.UpstreamError 构造时对 error.code / error.type 各走一次 ErrorToken
([A-Za-z0-9_.-]{<=64},不合规即空串):默认安全,不是可选加固。
- 构造后注入这条窄路收进 UpstreamErrorInfo.SetCode / SetType(同一道守卫),
供 DashScope 顶层 code 与 Anthropic 流内 error.type 两处使用 —— 调用方不再
各抄一份 ErrorToken。
- 删除因此冗余的调用方守卫:model.providerUpstreamError 与
voice.dashscopeOKBodyError。保留 voice 的 request_id 守卫(它不是 code/type,
构造函数看不见)。
- 四条漏的出口一行没改,只加用例证明守卫上移后自动安全;摘掉构造函数守卫时
四条用例 + model/voice 旧用例同时变红。
118 lines
6.1 KiB
Go
118 lines
6.1 KiB
Go
package voice
|
||
|
||
// 本文件是 DashScope 的「非 2xx 响应 → error」收口,与 M41 的 LLM / embedding /
|
||
// vision 三处同口径:走 internal/runtime/trace.UpstreamError(M40 共享 helper),
|
||
// 不自己再写一份「截一段响应体拼进 error」。
|
||
//
|
||
// 这一家的敏感面是 **TTS 的 input = 她要念的那段话**(与 emotionTTS 同型):
|
||
// DashScope 的 4xx 错误体里既有 `code` / `request_id`,也有自由文本 `message`,
|
||
// 而 message 会回显请求内容。因此正文只留在内存里做分类判据,出镜的只有
|
||
// 状态、体积摘要以及两个受控字段(code / request_id)。
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||
)
|
||
|
||
// classifyDashscopeStatus 把一个非 200 的 DashScope 响应翻成**可分类**的失败:鉴权失败 /
|
||
// 欠费额度 / 限流各自成一条哨兵链(都以 ErrProviderFailed 为底),其余原样归入
|
||
// ErrProviderFailed(切片 IM-ASR 的熔断据此判定「再试是不是白试」)。
|
||
//
|
||
// 判据只用**状态码 + 错误码/消息里的两类关键字**(阿里云官方口径:`Arrearage`、
|
||
// `Throttling.*`、额度上限),绝不匹配自由文本描述——把「格式不支持」误判成「欠费」
|
||
// 会让一条被拒的语音关掉整个转写面。
|
||
//
|
||
// 🔴 错误文案里**没有正文**:响应体只用来(a)在内存里做上面的关键字分类、
|
||
// (b)数长度 / 取 sha256 前缀、(c)解出 code 与 request_id。出镜的只有
|
||
// status / contentType / bodyLen / sha256 / code / request_id —— TTS 的请求
|
||
// input 就是**她要念的那段话**,把它(或回显它的 message)拼进 error 等于
|
||
// 把台词写进日志。
|
||
//
|
||
// 双 %w:errors.Is 既能命中分类哨兵(熔断判据),也能命中 trace.ErrUpstream。
|
||
func classifyDashscopeStatus(endpoint string, status int, contentType string, body []byte) error {
|
||
sentinel := dashscopeSentinelFor(status, strings.ToLower(string(body)))
|
||
fields := decodeDashscopeErrorFields(body)
|
||
info, ok := trace.UpstreamError(trace.HostOf(endpoint), status, contentType, body).(*trace.UpstreamErrorInfo)
|
||
if !ok {
|
||
// helper 的构造是固定的,走不到这里;真走到也只少一条诊断。
|
||
return fmt.Errorf("voice: %w", sentinel)
|
||
}
|
||
// 🔴 DashScope 把错误码放在**顶层** code 上(不是 error.code),构造函数看不见它,
|
||
// 因此这里属于「构造后注入」:走 SetCode(同一道令牌守卫)。这不是重复守卫 ——
|
||
// 去掉了它,散文形态的顶层 code 就会直接出镜(TestDashscopeTTS_Non200DropsFreeTextCode
|
||
// 会变红)。
|
||
info.SetCode(fields.code)
|
||
// request_id 不进 UpstreamErrorInfo,也不由构造函数解析:它是被**拼进错误文本**的
|
||
// 第三个自由文本面,所以它的守卫只能留在这里(这不是「同一件事守两遍」)。
|
||
if rid := trace.ErrorToken(fields.requestID); rid != "" {
|
||
return fmt.Errorf("voice: %w: request_id=%s: %w", sentinel, rid, info)
|
||
}
|
||
return fmt.Errorf("voice: %w: %w", sentinel, info)
|
||
}
|
||
|
||
// dashscopeSentinelFor 给出状态码/错误码对应的**分类**哨兵,无法分类时返回
|
||
// ErrProviderFailed。loweredBody 只用于关键字判定,绝不外泄 —— 分类是「再试
|
||
// 是不是白试」的判据,不是一条给人看的描述。
|
||
func dashscopeSentinelFor(status int, loweredBody string) error {
|
||
switch status {
|
||
case http.StatusUnauthorized, http.StatusForbidden:
|
||
return ErrAuthFailed
|
||
case http.StatusTooManyRequests:
|
||
return ErrRateLimited
|
||
}
|
||
// 欠费/额度耗尽:阿里云用 HTTP 400(少数情况 402)配上 Arrearage /
|
||
// AllocatedQuotaExceeded 这类错误码,因此按错误码关键字归类(不按状态码)。
|
||
switch {
|
||
case strings.Contains(loweredBody, "throttling"):
|
||
return ErrRateLimited
|
||
case strings.Contains(loweredBody, "arrearage"), strings.Contains(loweredBody, "quota"):
|
||
return ErrQuotaExceeded
|
||
}
|
||
return ErrProviderFailed
|
||
}
|
||
|
||
// dashscopeErrorFields 是 DashScope 非 2xx 响应体里**唯一**允许出镜的两个字段。
|
||
// message 不在此列:它是上游自由文本,TTS 场景下可能回显她要念的文本。
|
||
type dashscopeErrorFields struct {
|
||
code string
|
||
requestID string
|
||
}
|
||
|
||
// decodeDashscopeErrorFields 尽力解出 code / request_id。不是 JSON(纯文本或
|
||
// 被截断的报文)⇒ 两个字段都是空串,错误里只剩 status/体积/摘要 —— 正文一个
|
||
// 字节都不留。
|
||
func decodeDashscopeErrorFields(body []byte) dashscopeErrorFields {
|
||
var wire struct {
|
||
Code string `json:"code"`
|
||
RequestID string `json:"request_id"`
|
||
}
|
||
if err := json.Unmarshal(body, &wire); err != nil {
|
||
return dashscopeErrorFields{}
|
||
}
|
||
return dashscopeErrorFields{code: wire.Code, requestID: wire.RequestID}
|
||
}
|
||
|
||
// dashscopeOKBodyError 处理 DashScope 的「HTTP 200 但业务失败」形状:音色复刻
|
||
// (create_voice)用顶层 message 报错,因此**不走** classifyDashscopeStatus ——
|
||
// 200 这条路必须单独收口,否则它就成了这一族里唯一还漏的缺口。
|
||
//
|
||
// 只出镜一个**形状事实**(打到了、但 200 响应是个业务失败)加上体积摘要;
|
||
// message 原文一律不进 error:它是上游自由文本,而这条请求的 input.url 是参考
|
||
// 音频的公开地址(可能是预签名 URL,带凭据),上游报错经常把它整段回显出来。
|
||
// 病因分类保留在 ErrProviderFailed(再试也不会好,与既有的熔断口径一致);
|
||
// 确实带回来的 code/type 由 trace.UpstreamError **构造时**过令牌守卫(M49 上移),
|
||
// 这里不再守第二遍。
|
||
func dashscopeOKBodyError(endpoint string, body []byte) error {
|
||
info, ok := trace.UpstreamError(trace.HostOf(endpoint), http.StatusOK, "", body).(*trace.UpstreamErrorInfo)
|
||
if !ok {
|
||
// helper 的构造是固定的,走不到这里;真走到也只少一条诊断。
|
||
return fmt.Errorf("voice: %w: dashscope reported a business failure on a 200 response", ErrProviderFailed)
|
||
}
|
||
return fmt.Errorf("voice: %w: dashscope reported a business failure on a 200 response: %w",
|
||
ErrProviderFailed, info)
|
||
}
|