Files
Cyrene_v2/internal/runtime/multimodal/voice/dashscope_error.go
T
AskaEth 9e88605cc8 fix(voice): CloneVoice 的 200-with-message 泄漏收口到 dashscopeOKBodyError
M41 同族的最后一条缺口:HTTP 200 的响应里用顶层 message 报业务失败,而这句话
是上游自由文本 —— create_voice 的 input.url 是参考音频的公开地址(可能是预签名
URL,带凭据),上游报错常把它原样回显。

- ERROR 只留「打到了、但 200 是个业务失败」这个形状事实 + 体积摘要;
  message 原文不进 error,code/type 若真的带回来也只以令牌形态出镜
- errors.Is(err, ErrProviderFailed) 不变(熔断口径不动)
- 用例断言失败仍被识别、且 error 文本里搜不到哨兵正文
2026-10-26 13:50:10 +08:00

137 lines
6.5 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 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"
)
// maxErrorTokenBytes 是 error.code / request_id 允许出镜的长度上限(见
// dashscopeErrorToken)。
const maxErrorTokenBytes = 64
// 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)
}
info.Code = dashscopeErrorToken(fields.code)
if rid := dashscopeErrorToken(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 也只以令牌形态出镜(过 dashscopeErrorToken)。
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)
}
info.Code = dashscopeErrorToken(info.Code)
info.Type = dashscopeErrorToken(info.Type)
return fmt.Errorf("voice: %w: dashscope reported a business failure on a 200 response: %w",
ErrProviderFailed, info)
}
// dashscopeErrorToken 只放行「像协议令牌」的短串(字母、数字与 `_ . -`):code
// 在 DashScope 是枚举(InvalidParameter / Throttling.RateQuota / Arrearage …),
// request_id 是十六进制/UUID 形状。两者都是自由文本面,不像令牌的一律丢弃 ——
// 宁可少一条分类提示,不让散文借道出镜(同口径见 runtime/model 的 safeErrorToken)。
func dashscopeErrorToken(s string) string {
s = strings.TrimSpace(s)
if s == "" || len(s) > maxErrorTokenBytes {
return ""
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
case r == '_' || r == '.' || r == '-':
default:
return ""
}
}
return s
}