Files
AskaEth fe3872ee28 fix(trace): 令牌守卫上移进 trace.UpstreamError,构造即安全
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 旧用例同时变红。
2026-10-26 19:13:38 +08:00

118 lines
6.1 KiB
Go
Raw Permalink 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"
)
// 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)
}