Files
nyoungo c877c1bf8c fix(upstream): 额度耗尽立即轮换访客身份并识别 de/es/fr 额度文案
- 命中额度错误当场调用 RotateGuest(复用既有实现),不再等退避、也不再依赖 auto_refresh;后续路由与下一次请求直接用新访客身份
- quota_notice 信封(type=quota_notice / notice 对象)在通用 error 门之前判定,无 error 字段的额度通知不再被当成静默 delta
- 额度文案按站点语言识别:de guthaben/aufgebraucht、es agotado、fr épuisé/consomm、en quota/exhausted,并下钻 data/notice/quota_notice 嵌套字段
- cache message 失败与非 2xx 响应体也按同样规则归类,额度问题会走轮换与跨站 failover 而不是普通错误
2026-10-09 00:50:18 +08:00

519 lines
17 KiB
Go

package upstream
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
"github.com/nyoungo/dsfree2api/internal/config"
"github.com/nyoungo/dsfree2api/internal/httpx"
"github.com/nyoungo/dsfree2api/internal/turnstile"
)
const examplePath = "../../config.example.toml"
func testConfig(t *testing.T) *config.Config {
t.Helper()
cfg, err := config.Load(examplePath)
if err != nil {
t.Fatalf("load config: %v", err)
}
cfg.Upstream.AutoRefresh = false
cfg.Upstream.RefreshRetries = 0
return cfg
}
func TestRoutesKeepPrimaryFirstAndDedupe(t *testing.T) {
cfg := testConfig(t)
cfg.Proxy.URL = ""
cfg.Proxy.FallbackURLs = []string{"socks5://fallback:1", "socks5://fallback:1"}
c := New(cfg, nil, nil, nil)
got := c.Routes()
want := []Route{{Name: "primary", Proxy: ""}, {Name: "fallback-1", Proxy: "socks5://fallback:1"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("routes = %+v, want %+v", got, want)
}
}
func TestSlowPrimarySwitchesToFallbackBeforeContent(t *testing.T) {
cfg := testConfig(t)
cfg.Proxy.URL = ""
cfg.Proxy.FallbackURLs = []string{"socks5://fallback:1"}
cfg.Proxy.SlowStartSeconds = 0.02
var calls []string
c := New(cfg, nil, nil, nil)
c.chatOnceOverride = func(ctx context.Context, site config.Site, modelID string, model config.Model,
prompt string, route Route, info *ServeInfo, yield func(Event) error) error {
calls = append(calls, route.Name)
if route.Name == "primary" {
select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return ctx.Err()
}
yield(Event{Kind: KindDelta, Value: "primary"})
return nil
}
yield(Event{Kind: KindDelta, Value: "fallback"})
return nil
}
var got []string
if err := c.Chat(context.Background(), "deepseek-v4-flash-de", "hello", &ServeInfo{},
func(ev Event) error {
got = append(got, ev.Value)
return nil
}); err != nil {
t.Fatalf("chat: %v", err)
}
if want := []string{"primary", "fallback-1"}; !reflect.DeepEqual(calls, want) {
t.Errorf("calls = %v, want %v", calls, want)
}
if want := []string{"fallback"}; !reflect.DeepEqual(got, want) {
t.Errorf("events = %v, want %v", got, want)
}
}
func TestPrimaryErrorSwitchesToFallbackBeforeContent(t *testing.T) {
cfg := testConfig(t)
cfg.Proxy.URL = ""
cfg.Proxy.FallbackURLs = []string{"socks5://fallback:1"}
var calls []string
c := New(cfg, nil, nil, nil)
c.chatOnceOverride = func(ctx context.Context, site config.Site, modelID string, model config.Model,
prompt string, route Route, info *ServeInfo, yield func(Event) error) error {
calls = append(calls, route.Name)
if route.Name == "primary" {
return errors.New("primary failed")
}
yield(Event{Kind: KindDelta, Value: "fallback"})
return nil
}
var got []string
if err := c.Chat(context.Background(), "deepseek-v4-flash-de", "hello", &ServeInfo{},
func(ev Event) error {
got = append(got, ev.Value)
return nil
}); err != nil {
t.Fatalf("chat: %v", err)
}
if want := []string{"primary", "fallback-1"}; !reflect.DeepEqual(calls, want) {
t.Errorf("calls = %v, want %v", calls, want)
}
if want := []string{"fallback"}; !reflect.DeepEqual(got, want) {
t.Errorf("events = %v, want %v", got, want)
}
}
func TestNoFallbackAfterContentStarted(t *testing.T) {
cfg := testConfig(t)
cfg.Proxy.URL = ""
cfg.Proxy.FallbackURLs = []string{"socks5://fallback:1"}
var calls []string
c := New(cfg, nil, nil, nil)
c.chatOnceOverride = func(ctx context.Context, site config.Site, modelID string, model config.Model,
prompt string, route Route, info *ServeInfo, yield func(Event) error) error {
calls = append(calls, route.Name)
yield(Event{Kind: KindDelta, Value: "partial"})
return errors.New("stream failed")
}
err := c.Chat(context.Background(), "deepseek-v4-flash-de", "hello", &ServeInfo{},
func(Event) error { return nil })
if err == nil {
t.Fatal("expected an error")
}
var ue *UpstreamError
if !errors.As(err, &ue) {
t.Fatalf("error type = %T, want *UpstreamError", err)
}
if !ue.ContentStarted {
t.Error("expected ContentStarted to be set")
}
if want := []string{"primary"}; !reflect.DeepEqual(calls, want) {
t.Errorf("calls = %v, want %v", calls, want)
}
}
func TestQuotaObserverFiresOncePerSiteBeforeFailover(t *testing.T) {
cfg := testConfig(t)
cfg.Upstream.AutoRefresh = false
c := New(cfg, nil, nil, nil)
c.chatOnceOverride = func(ctx context.Context, site config.Site, modelID string, model config.Model,
prompt string, route Route, info *ServeInfo, yield func(Event) error) error {
if site.Code == "de" {
return newQuota(`sse quota exhausted: {"quota_notice":{}}`)
}
yield(Event{Kind: KindDelta, Value: "ok"})
return nil
}
var fired []string
c.SetQuotaObserver(func(site, model, route string, err error) {
fired = append(fired, site+"|"+model+"|"+route)
})
if err := c.Chat(context.Background(), "deepseek-v4-flash-de", "hi", &ServeInfo{},
func(ev Event) error { return nil }); err != nil {
t.Fatalf("chat should succeed via cross-site failover: %v", err)
}
if len(fired) != 1 || fired[0] != "de|deepseek-v4-flash-de|primary" {
t.Fatalf("quota observer events = %v, want [de|deepseek-v4-flash-de|primary]", fired)
}
}
func TestQuotaNoticeTriggersQuotaError(t *testing.T) {
payload := map[string]any{
"error": "Dein heutiges Guthaben von 10.000 Token (V4-Pro) ist aufgebraucht.",
"quota_notice": map[string]any{
"type": "quota_notice",
"message": "Dein heutiges Guthaben von 10.000 Token (V4-Pro) ist aufgebraucht.",
},
}
raw, _ := json.Marshal(payload)
_, err := translateEvent("error", []string{string(raw)})
if err == nil {
t.Fatal("expected error")
}
if !isQuota(err) {
t.Fatalf("want QuotaExhaustedError, got %T: %v", err, err)
}
if isSession(err) {
t.Error("quota error must not be a session error")
}
}
func TestSessionErrorTriggersSessionError(t *testing.T) {
raw, _ := json.Marshal(map[string]any{"error": "deepseek_ts_required"})
_, err := translateEvent("error", []string{string(raw)})
if err == nil {
t.Fatal("expected error")
}
if !isSession(err) {
t.Fatalf("want SessionVerificationError, got %T: %v", err, err)
}
if isQuota(err) {
t.Error("session error must not be a quota error")
}
}
func TestPlainSSEErrorDoesNotForceRefreshPath(t *testing.T) {
raw, _ := json.Marshal(map[string]any{"error": "temporary upstream failure"})
_, err := translateEvent("error", []string{string(raw)})
if err == nil {
t.Fatal("expected error")
}
if isQuota(err) || isSession(err) {
t.Fatalf("plain error must not be quota/session, got %T", err)
}
}
func TestGermanTsRequiredPayloadIsSessionError(t *testing.T) {
raw, _ := json.Marshal(map[string]any{
"error": "Sicherheitspruefung erforderlich.",
"ts_required": true,
})
_, err := translateEvent("error", []string{string(raw)})
if err == nil {
t.Fatal("expected error")
}
if !isSession(err) {
t.Fatalf("want SessionVerificationError, got %T: %v", err, err)
}
}
func TestTurnstileRequiredWhileDisabledFailsFast(t *testing.T) {
cfg := testConfig(t)
cfg.Proxy.URL = ""
cfg.Upstream.AutoRefresh = true
cfg.Upstream.RefreshRetries = 4
var calls int
c := New(cfg, nil, nil, nil)
c.chatOnceOverride = func(ctx context.Context, site config.Site, modelID string, model config.Model,
prompt string, route Route, info *ServeInfo, yield func(Event) error) error {
calls++
return newSession(`sse error: {"error":"Sicherheitspruefung erforderlich.","ts_required":true}`)
}
err := c.Chat(context.Background(), "deepseek-v4-flash-de", "hello", &ServeInfo{}, func(Event) error { return nil })
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(err.Error(), "requires Turnstile verification") {
t.Errorf("error = %q, want a Turnstile hint", err.Error())
}
if calls != 1 {
t.Errorf("upstream calls = %d, want 1 (no blind refresh retries)", calls)
}
}
func TestNonceFetchFailureFailsFast(t *testing.T) {
cfg := testConfig(t)
cfg.Proxy.URL = ""
cfg.Upstream.AutoRefresh = true
cfg.Upstream.RefreshRetries = 4
var calls int
c := New(cfg, nil, nil, nil)
c.chatOnceOverride = func(ctx context.Context, site config.Site, modelID string, model config.Model,
prompt string, route Route, info *ServeInfo, yield func(Event) error) error {
calls++
return errf("nonce fetch failed for %s: <!DOCTYPE html>", modelID)
}
err := c.Chat(context.Background(), "deepseek-v4-flash-de", "hello", &ServeInfo{}, func(Event) error { return nil })
if err == nil {
t.Fatal("expected an error")
}
if calls != 1 {
t.Errorf("upstream calls = %d, want 1 (config problems are not retried)", calls)
}
if !isConfigProblem(err) {
t.Errorf("err = %v, want isConfigProblem", err)
}
}
func TestSSEDeltaAndDoneEvents(t *testing.T) {
raw, _ := json.Marshal(map[string]any{"delta": "hello world"})
evs, err := translateEvent("message", []string{string(raw)})
if err != nil {
t.Fatalf("translate: %v", err)
}
if len(evs) != 1 || evs[0].Kind != KindDelta || evs[0].Value != "hello world" {
t.Fatalf("events = %+v", evs)
}
evs, err = translateEvent("message", []string{"[DONE]"})
if err != nil || len(evs) != 1 || evs[0].Kind != KindDone {
t.Fatalf("done event = %+v err=%v", evs, err)
}
}
func TestQuotaSiteCoolsDownAndPrefersSibling(t *testing.T) {
cfg := testConfig(t)
c := New(cfg, nil, nil, nil)
calls := map[string]int{}
c.chatOnceOverride = func(_ context.Context, site config.Site, modelID string, model config.Model,
_ string, _ Route, _ *ServeInfo, _ func(Event) error) error {
calls[site.Code]++
if site.Code == "de" {
return newQuota("sse quota exhausted: test")
}
return nil
}
emit := func(Event) error { return nil }
// Round 1: primary reports quota, a sibling serves the request.
if err := c.Chat(context.Background(), "deepseek-v4-flash-de", "p", nil, emit); err != nil {
t.Fatalf("first Chat: %v", err)
}
if calls["de"] != 1 {
t.Fatalf("de calls = %d, want 1", calls["de"])
}
if calls["es"]+calls["fr"] == 0 {
t.Fatal("sibling site was not attempted after primary quota error")
}
// Round 2: de sits on cooldown and must be skipped entirely.
delete(calls, "de")
delete(calls, "es")
delete(calls, "fr")
if err := c.Chat(context.Background(), "deepseek-v4-flash-de", "p", nil, emit); err != nil {
t.Fatalf("second Chat: %v", err)
}
if calls["de"] != 0 {
t.Fatalf("cooled site hit again: %d calls", calls["de"])
}
if calls["es"]+calls["fr"] == 0 {
t.Fatal("sibling site not used on second round")
}
if !c.quotaCooled("de") {
t.Fatal("de should still be cooling down")
}
}
func TestCacheEmptyFailsOverToMirrorWithoutRefreshCycles(t *testing.T) {
cfg := testConfig(t)
cfg.Upstream.AutoRefresh = true
cfg.Upstream.RefreshRetries = 3
c := New(cfg, nil, nil, nil)
calls := map[string]int{}
c.chatOnceOverride = func(_ context.Context, site config.Site, modelID string, model config.Model,
_ string, _ Route, _ *ServeInfo, _ func(Event) error) error {
calls[site.Code]++
if site.Code == "de" {
return errf(`cache message http 400: {"success":false,"data":{"code":"empty_data_to_cache"}}`)
}
return nil
}
if err := c.Chat(context.Background(), "deepseek-v4-flash-de", "p", nil, func(Event) error { return nil }); err != nil {
t.Fatalf("Chat: %v", err)
}
if calls["de"] != 1 {
t.Fatalf("de calls = %d, want 1 (cache rejection must fail fast, not burn refresh cycles)", calls["de"])
}
if calls["es"]+calls["fr"] == 0 {
t.Fatal("mirror was not attempted after cache rejection")
}
}
func TestQuotaRotationRecoversWithoutSolve(t *testing.T) {
cfg := testConfig(t)
cfg.Upstream.AutoRefresh = true
cfg.Upstream.RefreshRetries = 3
solver := turnstile.New(cfg.Turnstile, httpx.DefaultUserAgent)
const oldGid = "OLDGIDOLDGIDOLDGIDOLDGIDOLDG12"
if _, _, err := solver.ImportCookies("de", "cf_clearance=abc; dsgt_gid="+oldGid, ""); err != nil {
t.Fatalf("seed cookies: %v", err)
}
c := New(cfg, solver, nil, nil)
calls := 0
c.chatOnceOverride = func(_ context.Context, site config.Site, modelID string, model config.Model,
_ string, _ Route, _ *ServeInfo, _ func(Event) error) error {
calls++
if calls == 1 {
return newQuota("sse quota exhausted: test")
}
return nil
}
if err := c.Chat(context.Background(), "deepseek-v4-flash-de", "p", nil, func(Event) error { return nil }); err != nil {
t.Fatalf("Chat: %v", err)
}
if calls != 2 {
t.Fatalf("chat calls = %d, want 2 (quota then success)", calls)
}
gid, ok := solver.GuestID("de", "")
if !ok {
t.Fatal("guest id lost after rotation")
}
if gid == oldGid {
t.Fatal("visitor id was not rotated after the quota error")
}
if c.quotaCooled("de") {
t.Error("cooldown should be cleared once the site serves again")
}
}
// The three mirrors run the same server plugin in a different language, so a
// notice that carries no quota_notice envelope still has to be recognised.
func TestLocalizedQuotaNoticesAreQuotaErrors(t *testing.T) {
cases := []struct {
name string
eventType string
payload map[string]any
}{
{"de wording without envelope", "error", map[string]any{
"error": "Dein heutiges Guthaben von 30.000 Token (V4-Flash) ist aufgebraucht.",
}},
{"es wording", "error", map[string]any{
"error": "Tu saldo de tokens se ha agotado.",
}},
{"fr wording", "error", map[string]any{
"error": "Votre crédit de tokens est épuisé.",
}},
{"fr wording without accents", "error", map[string]any{
"error": "Votre credit de tokens est epuise pour aujourd'hui.",
}},
{"en wording", "error", map[string]any{
"error": "Your daily quota of tokens is exhausted.",
}},
{"notice event type without error field", "quota_notice", map[string]any{
"type": "quota_notice",
"notice": map[string]any{"message": "Dein Guthaben ist aufgebraucht."},
}},
{"bare notice object without error field", "quota_notice", map[string]any{
"notice": map[string]any{"message": "Tu saldo de tokens se ha agotado."},
}},
{"nested data wording", "error", map[string]any{
"error": map[string]any{"code": "quota_exhausted"},
"data": map[string]any{"message": "Le quota de tokens est consommé pour aujourd'hui."},
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
raw, _ := json.Marshal(tc.payload)
_, err := translateEvent(tc.eventType, []string{string(raw)})
if err == nil {
t.Fatal("expected an error, got none (the notice was swallowed)")
}
if !isQuota(err) {
t.Fatalf("want QuotaExhaustedError, got %T: %v", err, err)
}
if isSession(err) {
t.Error("quota error must not be classified as a session error")
}
})
}
}
func TestDeltaMentioningQuotaStaysDelta(t *testing.T) {
raw, _ := json.Marshal(map[string]any{"delta": "So prüfst du dein Guthaben: quota balance"})
evs, err := translateEvent("message", []string{string(raw)})
if err != nil {
t.Fatalf("a chat delta must never classify as an error: %v", err)
}
if len(evs) != 1 || evs[0].Kind != KindDelta {
t.Fatalf("events = %+v, want a single delta", evs)
}
}
// The allowance can also trip at the cache step, where the body carries
// success:false plus a localized message and no quota_notice envelope.
func TestCacheMessageLocalizedQuotaIsQuotaError(t *testing.T) {
bodies := []string{
`{"success":false,"data":{"code":"quota_exhausted","message":"Tu saldo de tokens se ha agotado."}}`,
`{"success":false,"data":{"message":"Votre crédit de tokens est épuisé."}}`,
`{"success":false,"error":"Dein heutiges Guthaben ist aufgebraucht."}`,
}
for _, body := range bodies {
if !isQuotaPayload(jsonPayload([]byte(body))) {
t.Errorf("body %s was not classified as quota", body)
}
}
if isQuotaPayload(jsonPayload([]byte(`{"success":false,"data":{"code":"empty_data_to_cache"}}`))) {
t.Error("cache rejection must not be classified as quota")
}
}
// Rotation must not depend on the refresh/retry machinery: with auto_refresh
// off the exhausted site still swaps in a fresh visitor id so the next request
// is billed to a balance that is not already spent.
func TestQuotaRotatesVisitorIdentityWithoutRefreshRetries(t *testing.T) {
cfg := testConfig(t) // AutoRefresh=false, RefreshRetries=0
solver := turnstile.New(cfg.Turnstile, httpx.DefaultUserAgent)
const oldGid = "OLDGIDOLDGIDOLDGIDOLDGIDOLDG12"
if _, _, err := solver.ImportCookies("de", "cf_clearance=abc; dsgt_gid="+oldGid, ""); err != nil {
t.Fatalf("seed cookies: %v", err)
}
for _, code := range []string{"es", "fr"} {
cfg.Sites[code].Enabled = false
}
c := New(cfg, solver, nil, nil)
c.chatOnceOverride = func(_ context.Context, site config.Site, modelID string, model config.Model,
_ string, _ Route, _ *ServeInfo, _ func(Event) error) error {
return newQuota("sse quota exhausted: test")
}
err := c.Chat(context.Background(), "deepseek-v4-flash-de", "p", nil, func(Event) error { return nil })
if err == nil {
t.Fatal("expected the quota error to surface once mirrors are disabled")
}
if !isQuota(err) {
t.Fatalf("want QuotaExhaustedError, got %T: %v", err, err)
}
gid, ok := solver.GuestID("de", "")
if !ok {
t.Fatal("guest id lost after rotation")
}
if gid == oldGid {
t.Fatal("visitor id was not rotated: rotation must not wait for a refresh retry")
}
}