Compare commits
2
Commits
4224fc2b12
...
3a0cc64160
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a0cc64160 | ||
|
|
fe3872ee28 |
No files matched your search
@@ -0,0 +1,56 @@
|
||||
package httpclient
|
||||
|
||||
// 本文件是 M49 的**出口回归探针**:令牌守卫上移进 trace.UpstreamError 之后,
|
||||
// 本客户端一行没改也应当自动安全 —— 上游把散文塞进 error.code / error.type 时,
|
||||
// 错误文本与日志里都搜不到它,而 status / body_bytes 这些诊断面照旧。
|
||||
//
|
||||
// 断言形状是「摘掉 trace.UpstreamError 里的守卫就变红」:这里**没有任何本地守卫**,
|
||||
// 也就没有任何本地补丁能掩盖缺口。M46 的教训正是「守卫加在调用方 ⇒ 新调用点默认
|
||||
// 不安全」,所以这一条故意不去调用点补。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDoJSON_StatusErrorDropsFreeTextUpstreamCode 4xx 报文的 error.code / error.type
|
||||
// 里带散文(带空格与标点,不像协议令牌)⇒ 两个字段整体消失,而状态码与体积摘要仍在。
|
||||
func TestDoJSON_StatusErrorDropsFreeTextUpstreamCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
const prose = "Invalid value: '她刚才说的那句话'"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"` + prose + `","type":"` + prose + `"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
|
||||
_, err := c.DoJSON(context.Background(), http.MethodPost, "/message/create", map[string]any{"a": 1})
|
||||
if err == nil {
|
||||
t.Fatal("502 却报成功")
|
||||
}
|
||||
if !errors.Is(err, ErrAPI) {
|
||||
t.Fatalf("err = %v, want ErrAPI", err)
|
||||
}
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("upstream call failed", "err", err)
|
||||
for _, surface := range []string{err.Error(), logBuf.String()} {
|
||||
if strings.Contains(surface, "她刚才说的那句话") {
|
||||
t.Fatalf("上游 error.code/type 里的散文进了错误或日志:%s", surface)
|
||||
}
|
||||
}
|
||||
// 可诊断性不能一起丢:状态码与体积摘要仍是分类与相关性比对的依据。
|
||||
for _, want := range []string{"status=502", "body_bytes="} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("错误里缺少 %q:%v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package embedding
|
||||
|
||||
// 本文件是 M49 的**出口回归探针**:令牌守卫上移进 trace.UpstreamError 之后,
|
||||
// embedding 这一处一行没改也应当自动安全 —— 兼容层把散文塞进 error.code /
|
||||
// error.type 时,错误文本与日志里都搜不到它,而 status / body_bytes 照旧。
|
||||
//
|
||||
// 断言形状是「摘掉 trace.UpstreamError 里的守卫就变红」:这里没有任何本地守卫。
|
||||
// 这一处最敏感:/embeddings 的 input 就是她的记忆正文,回显它的散文不能进日志。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// TestAPIEmbedder_Non200DropsFreeTextErrorCode 400 报文的 error.code / error.type
|
||||
// 里带散文 ⇒ 两个字段整体消失,而状态码与体积摘要仍在。
|
||||
func TestAPIEmbedder_Non200DropsFreeTextErrorCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
const prose = "Invalid value: '她刚才说的那句话'"
|
||||
rt := roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
body := `{"error":{"code":"` + prose + `","type":"` + prose + `"}}`
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
})
|
||||
emb := NewAPI(
|
||||
config.EmbeddingConfig{APIURL: "https://embed.example.com/v1/embeddings"},
|
||||
WithRoundTripper(rt),
|
||||
)
|
||||
|
||||
_, err := emb.Embed(context.Background(), []string{"hi"})
|
||||
if err == nil {
|
||||
t.Fatal("400 却报成功")
|
||||
}
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("embed call failed", "err", err)
|
||||
for _, surface := range []string{err.Error(), logBuf.String()} {
|
||||
if strings.Contains(surface, "她刚才说的那句话") {
|
||||
t.Fatalf("上游 error.code/type 里的散文进了错误或日志:%s", surface)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"status=400", "body_bytes="} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("错误里缺少 %q:%v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,10 +40,10 @@ func upstreamLabel(name, baseURL string) string {
|
||||
// 而这类 error 会被 `chat run` / `loop step` 打日志。截断到 512 字节只是把
|
||||
// 「整段泄漏」变成「前 512 字节泄漏」,不是修复。
|
||||
//
|
||||
// error.code 与 error.type 也要过令牌守卫(trace.ErrorToken):它们在主流上游是枚举,
|
||||
// 但网关、代理与兼容层可以往里面塞任何东西 —— 包括回显的输入。不像令牌的字段一律变空
|
||||
// 串,代价只是少一条分类提示;status / body 摘要等诊断面不受影响。两个字段用同一道
|
||||
// 守卫(不按字段分别处理),是因为「同一份上游报文,两个出口口径不一致」正是下发漏点。
|
||||
// error.code 与 error.type 的令牌守卫(trace.ErrorToken)**不在这里**:它已上移进
|
||||
// trace.UpstreamError 的构造函数(M49),上游报文里不像令牌的字段在那里就变空串。
|
||||
// 调用点再守一遍是 M46 留下的重复 —— 「同一件事守两遍」的下一步永远是两处口径分叉,
|
||||
// 所以这里删掉,只保留「构造即安全」这一处真源。
|
||||
//
|
||||
// 双 %w:既保持 errors.Is(err, ErrUpstream)(降级链按它分类,主 → 备 → 本地),
|
||||
// 也让 errors.Is(err, trace.ErrUpstream) 成立(调用方按上游失败分类)。
|
||||
@@ -55,8 +55,6 @@ func providerUpstreamError(name, baseURL string, resp *http.Response) error {
|
||||
// helper 的构造是固定的,走不到这里;真走到也只少一条诊断,不破安全口径。
|
||||
return fmt.Errorf("model: %w: status %d", ErrUpstream, resp.StatusCode)
|
||||
}
|
||||
info.Code = trace.ErrorToken(info.Code)
|
||||
info.Type = trace.ErrorToken(info.Type)
|
||||
return fmt.Errorf("model: %w: %w", ErrUpstream, info)
|
||||
}
|
||||
|
||||
@@ -65,14 +63,18 @@ func providerUpstreamError(name, baseURL string, resp *http.Response) error {
|
||||
//
|
||||
// evt.Message 是上游自由文本,Anthropic 会用它回显请求内容(例如「prompt is too
|
||||
// long: <片段>」),所以**整段丢弃**(原本是无上限拼进 error)。只保留 evt.Type
|
||||
// (协议枚举,如 overloaded_error)且必须像令牌才出镜:网关把散文塞进 type 时它
|
||||
// 是空串 —— 宁可少一条分类提示,不让自由文本借道 type 出去。
|
||||
// (协议枚举,如 overloaded_error)。
|
||||
//
|
||||
// 🔴 这里的类型是**流内事件**,报文不在 trace.UpstreamError 的视野里(HTTP 已 200,
|
||||
// body 为空),所以它属于「构造后注入」这一族,必须走 SetType(与构造函数同一道
|
||||
// 令牌守卫):网关把散文塞进 type 时它是空串 —— 宁可少一条分类提示,不让自由文本
|
||||
// 借道 type 出去。
|
||||
func streamEventError(upstream string, evt *anthropicStreamError) error {
|
||||
info, ok := trace.UpstreamError(upstream, 0, "text/event-stream", nil).(*trace.UpstreamErrorInfo)
|
||||
if !ok {
|
||||
// helper 的构造是固定的,走不到这里;真走到也只少一条诊断,不破安全口径。
|
||||
return fmt.Errorf("model: %w: anthropic stream error event", ErrUpstream)
|
||||
}
|
||||
info.Type = trace.ErrorToken(evt.Type)
|
||||
info.SetType(evt.Type)
|
||||
return fmt.Errorf("model: %w: %w", ErrUpstream, info)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package vision
|
||||
|
||||
// 本文件是 M49 的**出口回归探针**:令牌守卫上移进 trace.UpstreamError 之后,
|
||||
// 本适配器一行没改也应当自动安全 —— 视觉上游把散文塞进 error.code / error.type
|
||||
// 时,错误文本与日志里都搜不到它,而 status / body_bytes 这些诊断面照旧。
|
||||
//
|
||||
// 断言形状是「摘掉 trace.UpstreamError 里的守卫就变红」:这里没有任何本地守卫。
|
||||
// 视觉这一处尤其不能漏 —— 送上游的图像可能是她的照片,回显它的散文同样不能进日志。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOpenAICompatibleAdapter_DropsFreeTextUpstreamCode 4xx 报文的 error.code /
|
||||
// error.type 里带散文 ⇒ 两个字段整体消失,status 与体积摘要仍在。
|
||||
func TestOpenAICompatibleAdapter_DropsFreeTextUpstreamCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
const prose = "Invalid value: '她刚才说的那句话'"
|
||||
body := `{"error":{"code":"` + prose + `","type":"` + prose + `"}}`
|
||||
|
||||
ad := newAdapter("https://vision.example.com")
|
||||
ad.client = &http.Client{Transport: stubTransport(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}, nil
|
||||
})}
|
||||
|
||||
_, err := ad.Describe(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}, "")
|
||||
if err == nil {
|
||||
t.Fatal("400 却报成功")
|
||||
}
|
||||
if !errors.Is(err, ErrUpstream) {
|
||||
t.Fatalf("err = %v, want ErrUpstream", err)
|
||||
}
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("vision call failed", "err", err)
|
||||
for _, surface := range []string{err.Error(), logBuf.String()} {
|
||||
if strings.Contains(surface, "她刚才说的那句话") {
|
||||
t.Fatalf("上游 error.code/type 里的散文进了错误或日志:%s", surface)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"status=400", "body_bytes="} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("错误里缺少 %q:%v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,13 @@ func classifyDashscopeStatus(endpoint string, status int, contentType string, bo
|
||||
// helper 的构造是固定的,走不到这里;真走到也只少一条诊断。
|
||||
return fmt.Errorf("voice: %w", sentinel)
|
||||
}
|
||||
info.Code = trace.ErrorToken(fields.code)
|
||||
// 🔴 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)
|
||||
}
|
||||
@@ -98,15 +104,14 @@ func decodeDashscopeErrorFields(body []byte) dashscopeErrorFields {
|
||||
// message 原文一律不进 error:它是上游自由文本,而这条请求的 input.url 是参考
|
||||
// 音频的公开地址(可能是预签名 URL,带凭据),上游报错经常把它整段回显出来。
|
||||
// 病因分类保留在 ErrProviderFailed(再试也不会好,与既有的熔断口径一致);
|
||||
// 确实带回来的 code/type 也只以令牌形态出镜(过 trace.ErrorToken)。
|
||||
// 确实带回来的 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)
|
||||
}
|
||||
info.Code = trace.ErrorToken(info.Code)
|
||||
info.Type = trace.ErrorToken(info.Type)
|
||||
return fmt.Errorf("voice: %w: dashscope reported a business failure on a 200 response: %w",
|
||||
ErrProviderFailed, info)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package voice
|
||||
|
||||
// 本文件是 M49 的**出口回归探针**:DashScope 的「HTTP 200 但业务失败」收口里,
|
||||
// error.code / error.type 的守卫已经上移进 trace.UpstreamError —— 本包那处调用点的
|
||||
// 重复守卫已删除,因此这里的断言直接钉在**构造函数**上(摘掉构造函数的守卫就变红)。
|
||||
//
|
||||
// 与它相邻的一条(TestDashscopeTTS_Non200DropsFreeTextCode)钉的是另一件事:
|
||||
// DashScope 的 4xx 把错误码放在**顶层** code(构造函数看不见),那一处靠
|
||||
// UpstreamErrorInfo.SetCode 注入,守卫仍在。两条合起来覆盖这一族的两个面。
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDashscopeOKBodyError_DropsFreeTextErrorCode 200 报文的 error.code / error.type
|
||||
// 里带散文(TTS 场景下可能是她要念的台词被回显)⇒ 两个字段整体消失,而 status=200
|
||||
// 与 body_bytes(体积摘要)仍在。
|
||||
func TestDashscopeOKBodyError_DropsFreeTextErrorCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
const prose = "Invalid value: '她刚才说的那句话'"
|
||||
body := []byte(`{"error":{"code":"` + prose + `","type":"` + prose + `"}}`)
|
||||
|
||||
err := dashscopeOKBodyError("https://dashscope.example.com/api/v1/services/audio/tts", body)
|
||||
if !errors.Is(err, ErrProviderFailed) {
|
||||
t.Fatalf("err = %v, want ErrProviderFailed(熔断口径不变)", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "她刚才说的那句话") {
|
||||
t.Fatalf("上游 error.code/type 里的散文进了错误:%v", msg)
|
||||
}
|
||||
if strings.Contains(msg, " code=") || strings.Contains(msg, " type=") {
|
||||
t.Fatalf("非令牌形状的 code/type 没有被丢弃:%v", msg)
|
||||
}
|
||||
for _, want := range []string{"status=200", "body_bytes="} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("错误里缺少 %q(诊断面不能一起丢):%v", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,12 @@ import "strings"
|
||||
// 丢弃的代价只是少一条分类提示;保留一个不受控字段的代价是她的话进日志。这道守卫
|
||||
// 原先在 runtime/model(safeErrorToken)与 runtime/multimodal/voice
|
||||
// (dashscopeErrorToken)各有一份逐字同形的实现,M46 把同一份收敛到这里。
|
||||
//
|
||||
// 🔴 M49 收口:守卫的**应用点**同样收进本包 —— UpstreamError 构造时对 error.code /
|
||||
// error.type 各调一次(构造即安全,调用点不必也不该再守),构造后的注入走
|
||||
// UpstreamErrorInfo.SetCode / SetType(同一道守卫)。因此本包之外的 Go 代码里
|
||||
// **不应出现 ErrorToken 调用**:出现了就意味着又回到「调用方逐点补」的漂移形态
|
||||
// (M48 复审的 finding 正是这么漏的:httpclient / vision / embedding / weixin 四处)。
|
||||
|
||||
// maxErrorTokenBytes 是 error.code / error.type / request_id 允许出镜的长度上限。
|
||||
// 64 字节足够容纳任何真实的枚举与 UUID 形状,又远小于「一段话」。
|
||||
|
||||
@@ -18,6 +18,12 @@ import (
|
||||
// JSON 里的 error.code / error.type;**error.message 默认不带**,只有显式
|
||||
// opt-in(WithUpstreamMessage)才保留,且必须过 RedactText 再截到
|
||||
// maxUpstreamMessageRunes;
|
||||
// - 🔴 **code / type 构造时就过令牌守卫**(ErrorToken):只有像协议令牌的短串才
|
||||
// 出镜,散文一律变空串。守卫**在本构造函数里**,不在调用点 —— 因此任何新调用点
|
||||
// **默认就是安全的**。M46 曾把它加在调用方(model / voice 各一份),M48 复审的
|
||||
// 中危 finding 证明那正是漂移形态:调用方逐点补,四条出口(httpclient / vision /
|
||||
// embedding / weixin)就一直漏。M49 把守卫上移到这里,并把「构造后再注入」这一
|
||||
// 条窄路收进 SetCode / SetType(同样守卫)。
|
||||
// - 其余**一律丢弃**:响应体原文、请求参数、请求头、provider 自带的
|
||||
// param/input/code_id 之流,绝不进入 error 的文本,也绝不进入日志;
|
||||
// - Error() 与 Attrs() 是**唯二**的出口,两者都只渲染上面那些字段,
|
||||
@@ -63,9 +69,12 @@ type UpstreamErrorInfo struct {
|
||||
// BodySHA256 是响应体 sha256 的十六进制前缀,用于相关性比对(同一份报文能
|
||||
// 认出来,正文拿不到)。
|
||||
BodySHA256 string
|
||||
// Code 是上游 JSON 里 error.code(字符串或数字;数字按十进制渲染)。
|
||||
// Code 是上游 JSON 里 error.code(字符串或数字;数字按十进制渲染),**构造时已过
|
||||
// 令牌守卫**(与 SetCode 同一道):不像协议令牌的值在这里恒为空串。空串是预期
|
||||
// 结果 —— 宁可不显示一条分类提示,也不让正文借道字段出镜;调用方不得按「是否为空」
|
||||
// 推断「上游没给码」(两者在构造后不可区分)。
|
||||
Code string
|
||||
// Type 是上游 JSON 里 error.type。
|
||||
// Type 是上游 JSON 里 error.type,**构造时已过令牌守卫**,口径与 Code 逐字相同。
|
||||
Type string
|
||||
// Message 是上游 JSON 里 error.message,**只有显式 opt-in(WithUpstreamMessage)
|
||||
// 才非空**:那一刻它已压平空白、清洗凭据形状并截断到
|
||||
@@ -182,6 +191,12 @@ func WithUpstreamMessage() UpstreamOption {
|
||||
// 宁可少一条人话提示,也不让她的原话进日志;status/code/type/bodyLen/sha256
|
||||
// 足够分类与相关性比对。确实要看那句话时,在**确认该上游不回显用户内容**的调用点
|
||||
// 加 WithUpstreamMessage(那时它仍会被 RedactText 清洗并截到 128 rune)。
|
||||
//
|
||||
// 🔴 同样默认的还有 **code / type 的令牌守卫**:它们不是「可选加固」,而是本构造函数
|
||||
// 的固定行为 —— 报文里的 error.code / error.type 只有像协议令牌(≤64 字节、仅
|
||||
// [A-Za-z0-9_.-])才出镜,其余变空串。调用方**不需要**、也**不要**再守一遍:守卫
|
||||
// 加在调用点意味着新调用点默认不安全(M46 → M49 的教训)。构造后要注入这两个字段
|
||||
// 时走 SetCode / SetType,别直接赋值。
|
||||
func UpstreamError(upstream string, status int, contentType string, body []byte, opts ...UpstreamOption) error {
|
||||
var cfg upstreamOptions
|
||||
for _, opt := range opts {
|
||||
@@ -199,8 +214,10 @@ func UpstreamError(upstream string, status int, contentType string, body []byte,
|
||||
info.BodySHA256 = DigestOf(string(body)).SHA256
|
||||
}
|
||||
if parsed, ok := parseUpstreamErrorBody(body); ok {
|
||||
info.Code = parsed.Code
|
||||
info.Type = parsed.Type
|
||||
// 🔴 构造即安全(M49):code / type 在这里过令牌守卫,调用点不需要也不应该
|
||||
// 再守一遍 —— 守卫留在调用方的形态必然漏点(新调用点默认不安全)。
|
||||
info.Code = ErrorToken(parsed.Code)
|
||||
info.Type = ErrorToken(parsed.Type)
|
||||
if cfg.keepMessage {
|
||||
info.Message = sanitizeUpstreamMessage(parsed.Message)
|
||||
}
|
||||
@@ -208,6 +225,21 @@ func UpstreamError(upstream string, status int, contentType string, body []byte,
|
||||
return info
|
||||
}
|
||||
|
||||
// SetCode 是**构造之后**改写 Code 的唯一合法通道:它过的是与构造函数同一道令牌守卫
|
||||
// (ErrorToken),所以「写入即安全」。
|
||||
//
|
||||
// 为什么还需要这条窄路:有些上游把错误码放在**顶层** code(DashScope 的 4xx 报文)、
|
||||
// 或者把失败放进一个已经 200 的**流内事件**里(Anthropic SSE 的 error.type)——
|
||||
// 这两种报文都不在 UpstreamError 参数的视野里,只能构造后注入。注入点也是守卫点,
|
||||
// 但守卫代码留在本包:调用点只写 `info.SetCode(raw)`,不再各抄一份 ErrorToken
|
||||
// (M46 的两份实现就是这么漂移出来的)。
|
||||
//
|
||||
// 语义是**覆盖**:注入的值不像令牌时 Code 变空串,而不是保留构造函数解出来的旧值。
|
||||
func (e *UpstreamErrorInfo) SetCode(code string) { e.Code = ErrorToken(code) }
|
||||
|
||||
// SetType 是 Type 版的 SetCode,口径逐字相同。
|
||||
func (e *UpstreamErrorInfo) SetType(typ string) { e.Type = ErrorToken(typ) }
|
||||
|
||||
// upstreamErrorBody 是**唯一**从响应体里读出的结构:只有 error 的三个字段,
|
||||
// 其余字段(param / input / code_id / 请求回显 …)根本不会被解码到任何地方。
|
||||
type upstreamErrorBody struct {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package trace
|
||||
|
||||
// 本文件是 M49 的核心断言:**守卫在构造函数里**。
|
||||
//
|
||||
// M46 把令牌守卫加在调用方(model / voice 各一份),M48 复审的中危 finding 证明那是
|
||||
// 漂移形态 —— 构造函数自己不守,任何新调用点默认就不安全,四条出口(httpclient /
|
||||
// vision / embedding / weixin)恰恰一直漏着。这里把断言钉在 UpstreamError 与
|
||||
// SetCode / SetType 上:摘掉构造函数里的守卫,本文件与四条出口的用例同时变红。
|
||||
//
|
||||
// 每条都是「把它放宽就变红」的形状:断言的是真正进日志的两个出口(Error() / Attrs())。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestUpstreamError_ConstructorDropsFreeTextCodeAndType 报文里 error.code 与
|
||||
// error.type 都是回显的散文 ⇒ 两个字段在构造时就变空串,而 status / body_bytes /
|
||||
// body_sha256 仍在(诊断面不能一起丢)。
|
||||
func TestUpstreamError_ConstructorDropsFreeTextCodeAndType(t *testing.T) {
|
||||
t.Parallel()
|
||||
const prose = "Invalid value: '她刚才说的那句话'"
|
||||
body := []byte(`{"error":{"code":"` + prose + `","type":"` + prose + `"}}`)
|
||||
|
||||
err := UpstreamError("api.example.com", 400, "application/json", body)
|
||||
info, ok := err.(*UpstreamErrorInfo)
|
||||
if !ok {
|
||||
t.Fatalf("err 类型 = %T, want *UpstreamErrorInfo", err)
|
||||
}
|
||||
if info.Code != "" || info.Type != "" {
|
||||
t.Fatalf("info.Code/Type = %q/%q,want 空串(构造即安全)", info.Code, info.Type)
|
||||
}
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("upstream call failed", "err", err)
|
||||
for _, surface := range []string{err.Error(), logBuf.String(), strings.Join(anyToStrings(info.Attrs()), " ")} {
|
||||
if strings.Contains(surface, "她刚才说的那句话") {
|
||||
t.Fatalf("散文经 code/type 进了出口:%s", surface)
|
||||
}
|
||||
if strings.Contains(surface, " code=") || strings.Contains(surface, " type=") {
|
||||
t.Fatalf("非令牌形状的 code/type 没有被丢弃:%s", surface)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"status=400", "body_bytes=", "body_sha256=", "api.example.com"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %q,want 含 %q(诊断线索不能一起丢)", err.Error(), want)
|
||||
}
|
||||
}
|
||||
if !errors.Is(err, ErrUpstream) {
|
||||
t.Fatal("errors.Is(err, ErrUpstream) = false;降级链无法按类别判断")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpstreamError_ConstructorKeepsProtocolTokenCodeAndType 真实的协议枚举必须原样
|
||||
// 通过:丢了它们只是少一条分类提示,但那是**有价值的**提示,别顺手一起丢。
|
||||
func TestUpstreamError_ConstructorKeepsProtocolTokenCodeAndType(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := []byte(`{"error":{"code":"invalid_request_error","type":"Throttling.RateQuota"}}`)
|
||||
err := UpstreamError("api.example.com", 429, "application/json", body)
|
||||
info := err.(*UpstreamErrorInfo)
|
||||
if info.Code != "invalid_request_error" || info.Type != "Throttling.RateQuota" {
|
||||
t.Fatalf("info.Code/Type = %q/%q,want 原值(令牌形状必须保留)", info.Code, info.Type)
|
||||
}
|
||||
for _, want := range []string{"code=invalid_request_error", "type=Throttling.RateQuota"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %q,want 含 %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpstreamError_SetCodeGuardsPostConstructionInjection 构造后注入这条窄路也必须
|
||||
// 是守卫点:SetCode / SetType 是唯一合法通道,散文进去 = 空串出来(覆盖语义)。
|
||||
func TestUpstreamError_SetCodeGuardsPostConstructionInjection(t *testing.T) {
|
||||
t.Parallel()
|
||||
const prose = "Invalid value: '她刚才说的那句话'"
|
||||
info := UpstreamError("dashscope.example.com", 400, "application/json", nil).(*UpstreamErrorInfo)
|
||||
|
||||
info.SetCode("Throttling.RateQuota")
|
||||
info.SetType("rate-limit")
|
||||
if info.Code != "Throttling.RateQuota" || info.Type != "rate-limit" {
|
||||
t.Fatalf("info.Code/Type = %q/%q,want 令牌原值", info.Code, info.Type)
|
||||
}
|
||||
|
||||
// 注入不受控的散文:两个字段都变空串(而不是保留上一次的值)。
|
||||
info.SetCode(prose)
|
||||
info.SetType(prose)
|
||||
if info.Code != "" || info.Type != "" {
|
||||
t.Fatalf("info.Code/Type = %q/%q,want 空串(SetXxx 必须过同一道守卫)", info.Code, info.Type)
|
||||
}
|
||||
if text := info.Error(); strings.Contains(text, "她刚才说的那句话") {
|
||||
t.Fatalf("SetCode/SetType 让散文进了错误文本:%s", text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package weixin_ilink
|
||||
|
||||
// 本文件是 M49 的**出口回归探针**:令牌守卫上移进 trace.UpstreamError 之后,
|
||||
// iLink 适配器一行没改也应当自动安全 —— 上游把散文塞进 error.code / error.type
|
||||
// 时,错误文本与日志里都搜不到它,而 status / body_bytes 照旧。
|
||||
//
|
||||
// 断言形状是「摘掉 trace.UpstreamError 里的守卫就变红」:这里没有任何本地守卫。
|
||||
// iLink 会在 errmsg 里回显请求内容(发出去的就是她说的话),所以这一条不能漏。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDoJSON_StatusErrorDropsFreeTextUpstreamCode 4xx 报文的 error.code /
|
||||
// error.type 里带散文 ⇒ 两个字段整体消失,状态码与体积摘要仍在。
|
||||
func TestDoJSON_StatusErrorDropsFreeTextUpstreamCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
const prose = "Invalid value: '她刚才说的那句话'"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"` + prose + `","type":"` + prose + `"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ad := newAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
|
||||
err := ad.doJSON(context.Background(), http.MethodPost, ad.apiURL("/ilink/bot/sendmessage"), nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("400 却报成功")
|
||||
}
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("ilink call failed", "err", err)
|
||||
for _, surface := range []string{err.Error(), logBuf.String()} {
|
||||
if strings.Contains(surface, "她刚才说的那句话") {
|
||||
t.Fatalf("上游 error.code/type 里的散文进了错误或日志:%s", surface)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"status=400", "body_bytes="} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("错误里缺少 %q:%v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user