Files
Cyrene_v2/internal/runtime/embedding/api_url_test.go
T
AskaEth 987cefdf28
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(embedding): 端点地址规范化 + 独立短超时(治入库审查整条超时)
背景:入库前审查链路一直整条超时(日志每 ~20s 一条 WARN),异步那层
(含刚装好的 T1)从未成功跑过。

- 新增 endpoint.go:把「连接语义上不可靠」的回环别名规范化成 127.0.0.1
  (localhost 大小写不敏感 / 0.0.0.0 / 空主机),只替换 Host,不碰
  path / query / fragment / userinfo;显式 [::1] 与真实主机名逐字不动
- Embed 加独立短超时 DefaultRequestTimeout = 5s(远小于审查 20s 预算):
  卡住的调用快速失败并走既有「降级到词项余弦」的路径,把预算留给 T1
- .env.example 示例地址 localhost -> 127.0.0.1(未注释行键数 69 -> 69,无增删)
- 跨包测试钉住 5s 严格小于审查总预算

诚实边界(子代理实测,推翻了主代理原先的 IPv6 根因推断):
- Go 客户端走 localhost 实测约 57ms、curl 约 0.2s ⇒「localhost -> ::1
  等满 20s 预算」这条链不成立;更可能是单槽 llama.cpp 的吞吐/争用
  (一批 64 条约 2.1~2.9s,审查 201 条约 8~10s,再叠 T1 8s 顶满预算)
- 规范化在任何环境下都无害,5s 快速失败对这个真因对症;但真因
  (容量/争用)需要另排切片,已记入工作小结
2026-10-07 16:22:28 +08:00

266 lines
9.3 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 embedding
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// TestNormalizeEndpointURL 钉住「回环别名 → 127.0.0.1」这张表,以及**不该被动**
// 的那些输入。全部是纯字符串断言,不连任何网络。
func TestNormalizeEndpointURL(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want string
}{
{
name: "localhost 改写",
in: "http://localhost:11434/v1/embeddings",
want: "http://127.0.0.1:11434/v1/embeddings",
},
{
name: "localhost 大小写不敏感",
in: "http://LocalHost:11434/v1/embeddings",
want: "http://127.0.0.1:11434/v1/embeddings",
},
{
name: "localhost 无端口",
in: "http://localhost/v1/embeddings",
want: "http://127.0.0.1/v1/embeddings",
},
{
name: "https 同样改写",
in: "https://localhost:8443/v1/embeddings",
want: "https://127.0.0.1:8443/v1/embeddings",
},
{
// 0.0.0.0 是「监听所有接口」的语义,不是可连接的地址(拿它当连接目标会
// 立即失败),因此这里是**防呆**:把写坏的地址掰回真正可连的回环字面量,
// 而不是「支持用 0.0.0.0 去连」。
name: "0.0.0.0 防呆改写",
in: "http://0.0.0.0:11434/v1/embeddings",
want: "http://127.0.0.1:11434/v1/embeddings",
},
{
// 空主机同样只是防呆:原文拼出来的 URL 指向不了任何服务。
name: "空主机防呆改写",
in: "http:///v1/embeddings",
want: "http://127.0.0.1/v1/embeddings",
},
{
name: "显式 IPv6 字面量不改写",
in: "http://[::1]:11434/v1/embeddings",
want: "http://[::1]:11434/v1/embeddings",
},
{
name: "已是 127.0.0.1 原样返回",
in: "http://127.0.0.1:11434/v1/embeddings",
want: "http://127.0.0.1:11434/v1/embeddings",
},
{
name: "远端主机名不改写",
in: "https://embed.example.com/v1/embeddings",
want: "https://embed.example.com/v1/embeddings",
},
{
// 路径与查询串必须逐字不变:包括百分号编码、`+`、重复键与空值。
name: "路径与查询串逐字不变",
in: "http://localhost:8080/v1/emb%65ddings?a=%2F+&b=1&b=&empty#frag",
want: "http://127.0.0.1:8080/v1/emb%65ddings?a=%2F+&b=1&b=&empty#frag",
},
{
// 未改写的主机(含显式 IPv6)必须**原串**返回,不做任何重排。
name: "未改写时逐字返回原串",
in: "https://example.com:443/v1/a%2Fb?q=%7B%7D&r=1#f",
want: "https://example.com:443/v1/a%2Fb?q=%7B%7D&r=1#f",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := normalizeEndpointURL(tc.in)
if err != nil {
t.Fatalf("normalizeEndpointURL(%q) error: %v", tc.in, err)
}
if got != tc.want {
t.Fatalf("normalizeEndpointURL(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestNormalizeEndpointURL_NonHTTPSchemeUntouched 保证非 http(s) 的输入不被改写:
// 那类地址不是本客户端能拨的,改主机只会掩盖更该暴露的配置错误。
func TestNormalizeEndpointURL_NonHTTPSchemeUntouched(t *testing.T) {
t.Parallel()
const in = "file://localhost/v1/embeddings"
got, err := normalizeEndpointURL(in)
if err != nil {
t.Fatalf("normalizeEndpointURL(%q) error: %v", in, err)
}
if got != in {
t.Fatalf("normalizeEndpointURL(%q) = %q, want unchanged", in, got)
}
}
// TestAPIEmbedder_NormalizesLoopbackHost 从**发出的请求**上验证规范化确实生效:
// 主机拨号目标变成 127.0.0.1,且 path + query 一字未动。用注入的 RoundTripper,
// 不连任何网络。
func TestAPIEmbedder_NormalizesLoopbackHost(t *testing.T) {
t.Parallel()
var gotHost, gotURI string
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
gotHost = r.URL.Host
gotURI = r.URL.RequestURI()
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"data":[{"index":0,"embedding":[0.1]}]}`)),
Header: make(http.Header),
}, nil
})
emb := NewAPI(
config.EmbeddingConfig{APIURL: "http://localhost:11434/v1/embeddings?k=v#frag"},
WithRoundTripper(rt),
)
if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil {
t.Fatalf("Embed() error: %v", err)
}
if gotHost != "127.0.0.1:11434" {
t.Errorf("dial host = %q, want 127.0.0.1:11434", gotHost)
}
if gotURI != "/v1/embeddings?k=v" {
t.Errorf("request URI = %q, want /v1/embeddings?k=v", gotURI)
}
}
// TestAPIEmbedder_KeepsExplicitIPv6Host 尊重使用者显式写下的 IPv6 字面量:
// 不改写、不重排。同样走注入的 RoundTripper,不连网络。
func TestAPIEmbedder_KeepsExplicitIPv6Host(t *testing.T) {
t.Parallel()
var gotHost string
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
gotHost = r.URL.Host
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"data":[{"index":0,"embedding":[0.1]}]}`)),
Header: make(http.Header),
}, nil
})
emb := NewAPI(
config.EmbeddingConfig{APIURL: "http://[::1]:11434/v1/embeddings"},
WithRoundTripper(rt),
)
if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil {
t.Fatalf("Embed() error: %v", err)
}
if gotHost != "[::1]:11434" {
t.Errorf("dial host = %q, want [::1]:11434(显式 IPv6 不许被改写)", gotHost)
}
}
// TestAPIEmbedder_LoopbackAliasReachesIPv4OnlyServer 是**真连本机**的场景验证
// (httptest 服务器只绑 127.0.0.1,正是 llama.cpp / ollama 的默认形态):
// 端点写成 localhost / 0.0.0.0 时,请求必须仍然落到那个 IPv4 回环服务上并拿回向量。
//
// 注意它证明的是「场景通」而非「不做规范化就必挂」:2026-10-07 在本机实测,
// Go 客户端的拨号器对 localhost 会很快回落到 127.0.0.1(约 57ms),把这一条测试
// 的规范化关掉它**仍然会通过**。真正把这条测试变红的是 path/query 被破坏之类的
// 回归;「localhost 一定不去碰 ::1」这个保证由上面的字符串断言守住。
// 全程只连本进程起在 127.0.0.1 上的临时服务器,不碰任何外部服务。
func TestAPIEmbedder_LoopbackAliasReachesIPv4OnlyServer(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"data":[{"index":0,"embedding":[0.1,0.2]}]}`)
}))
defer srv.Close()
for _, alias := range []string{"localhost", "0.0.0.0"} {
alias := alias
// 刻意**不用** t.Parallel():子测试若并行,父测试会先返回并执行
// defer srv.Close(),子测试再跑时就只能撞上「连接被拒绝」。
t.Run(alias, func(t *testing.T) {
// srv.URL 形如 http://127.0.0.1:PORT —— 只把主机换成别名,端口保留,
// 这样「服务确实只绑 IPv4」这个前提不变。
raw := strings.Replace(srv.URL, "127.0.0.1", alias, 1) + "/v1/embeddings"
emb := NewAPI(config.EmbeddingConfig{APIURL: raw})
vectors, err := emb.Embed(context.Background(), []string{"hi"})
if err != nil {
t.Fatalf("端点写成 %s 时未落到 IPv4 回环服务:%v", alias, err)
}
if len(vectors) != 1 || len(vectors[0]) != 2 {
t.Fatalf("vectors = %v, want 1 个 2 维向量", vectors)
}
})
}
}
// reviewBudgetRef 是「入库前审查」单条总预算的**参照值**,只用于本文件的断言:
// 权威定义在 internal/runtime/memory/preinsert_config.go 的 defaultReviewTimeout。
// 跨包的真实断言在 memory 包(preinsert_recall_test.go)里,这里钉住同一个口径,
// 免得 embedding 侧悄悄把超时调到 ≥ 审查预算。
const reviewBudgetRef = 20 * time.Second
// TestDefaultRequestTimeout 钉住单次 embedding 调用的超时值,并断言它**严格小于**
// 审查总预算:否则一次卡死的 embedding 连接就能吃光整条审查的预算,排在后面的
// T1 灰区判定连执行机会都没有(2026-10-07 实测故障形态)。同时验证该超时确实
// 打在了发出去的请求上。
func TestDefaultRequestTimeout(t *testing.T) {
t.Parallel()
if DefaultRequestTimeout <= 0 {
t.Fatalf("DefaultRequestTimeout = %s, 必须为正", DefaultRequestTimeout)
}
if DefaultRequestTimeout >= reviewBudgetRef {
t.Fatalf("embedding 单次超时 %s 必须严格小于审查总预算 %s",
DefaultRequestTimeout, reviewBudgetRef)
}
if DefaultRequestTimeout != 5*time.Second {
t.Fatalf("DefaultRequestTimeout = %s, want 5s(值变了要连带复核审查预算口径)",
DefaultRequestTimeout)
}
var slack time.Duration
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
dl, ok := r.Context().Deadline()
if !ok {
t.Error("请求上下文没有 deadline,单次超时未生效")
} else {
slack = time.Until(dl)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"data":[{"index":0,"embedding":[0.1]}]}`)),
Header: make(http.Header),
}, nil
})
emb := NewAPI(
config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt),
)
if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil {
t.Fatalf("Embed() error: %v", err)
}
if slack <= 0 || slack > DefaultRequestTimeout {
t.Fatalf("请求 deadline 余量 = %s, want (0, %s]", slack, DefaultRequestTimeout)
}
}