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