Files
sub2api/backend/internal/service/gemini_cached_content_not_found_test.go
T
feeeei 3abd646582 fix(ops): Gemini 原生路由的网关本地错误按错误类型归类
Google 格式错误体没有 type 字段,ops 一律记为 api_error,客户端参数错误、
引用不存在的缓存等都被归为平台内部错误。

- googleError 写出时按状态码向 ops 登记错误类型:400/413 invalid_request_error、
  401 authentication_error、403 permission_error、404 not_found_error,
  429 与 5xx 不登记
- 计费、并发与风控拦截沿用 Claude / OpenAI 格式错误的类型与错误码,归类口径一致
- 存储价未配置、显式缓存未启用改用 googlePlatformError,仍按平台错误统计;
  错误透传规则改写的上游错误不登记本地类型
- 上游返回的缓存不存在标记为请求级失败:按客户端请求错误分类,
  上游原始错误保留为明细但不参与上游归因
2026-10-10 23:37:48 +08:00

187 lines
8.7 KiB
Go

//go:build unit
package service
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// 上游对不存在、已过期、已删除或不属于当前项目的显式缓存一律返回这个 403。
const geminiCachedContentNotFoundBody = `{"error":{"code":403,"message":"CachedContent not found (or permission denied)","status":"PERMISSION_DENIED"}}`
func newGeminiCachedContentTestService(status int, body string) (*GeminiMessagesCompatService, *geminiCompatHTTPUpstreamStub, *errorPolicyRepoStub) {
repo := &errorPolicyRepoStub{}
httpStub := &geminiCompatHTTPUpstreamStub{
response: &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
},
}
svc := &GeminiMessagesCompatService{
httpUpstream: httpStub,
cfg: &config.Config{},
rateLimitService: NewRateLimitService(repo, nil, &config.Config{}, nil, nil),
}
return svc, httpStub, repo
}
func geminiDefaultPolicyAPIKeyAccount() *Account {
return &Account{
ID: 710,
Platform: PlatformGemini,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"api_key": "test-key",
},
}
}
const geminiCachedContentRequestBody = `{"cachedContent":"cachedContents/doesnotexist","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`
func TestGeminiForwardNative_CachedContentNotFoundPassthroughWithoutAccountPenalty(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name string
action string
stream bool
}{
{name: "generateContent", action: "generateContent"},
{name: "streamGenerateContent", action: "streamGenerateContent", stream: true},
{name: "countTokens", action: "countTokens"},
} {
t.Run(tc.name, func(t *testing.T) {
svc, httpStub, repo := newGeminiCachedContentTestService(http.StatusForbidden, geminiCachedContentNotFoundBody)
c, rec := newGeminiNativeTestContext(t)
result, err := svc.ForwardNative(context.Background(), c, geminiDefaultPolicyAPIKeyAccount(),
"gemini-3.8-flash", tc.action, tc.stream, []byte(geminiCachedContentRequestBody))
require.Nil(t, result)
require.Error(t, err)
var failoverErr *UpstreamFailoverError
require.False(t, errors.As(err, &failoverErr), "缓存不存在是请求问题,不应换号")
require.Equal(t, 0, repo.setErrCalls, "不应把账号标记为错误")
require.Equal(t, 0, repo.tempCalls, "不应临时停调度")
require.Equal(t, 1, httpStub.calls, "不应重试")
require.Equal(t, http.StatusForbidden, rec.Code)
require.Equal(t, GeminiCachedContentNotFoundResponse, rec.Body.String(), "应返回标准缓存不存在响应")
require.True(t, IsOpsRequestScopedError(c), "缓存不存在按客户端请求错误统计")
})
}
}
func TestGeminiForwardNative_CachedContentNotFoundBypassesTempUnschedulableRules(t *testing.T) {
gin.SetMode(gin.TestMode)
svc, _, repo := newGeminiCachedContentTestService(http.StatusForbidden, geminiCachedContentNotFoundBody)
c, rec := newGeminiNativeTestContext(t)
account := geminiDefaultPolicyAPIKeyAccount()
account.Credentials["temp_unschedulable_enabled"] = true
account.Credentials["temp_unschedulable_rules"] = []any{
map[string]any{
"error_code": float64(403),
"keywords": []any{"permission denied"},
"duration_minutes": float64(10),
"description": "403 permission denied",
},
}
_, err := svc.ForwardNative(context.Background(), c, account,
"gemini-3.8-flash", "generateContent", false, []byte(geminiCachedContentRequestBody))
require.Error(t, err)
var failoverErr *UpstreamFailoverError
require.False(t, errors.As(err, &failoverErr))
require.Equal(t, 0, repo.tempCalls, "临时不可调度规则不应命中")
require.Equal(t, 0, repo.setErrCalls)
require.Equal(t, http.StatusForbidden, rec.Code)
}
func TestGeminiForwardNative_OtherForbiddenStillPenalizesAccount(t *testing.T) {
gin.SetMode(gin.TestMode)
body := `{"error":{"code":403,"message":"Your project has been denied access. Please contact support.","status":"PERMISSION_DENIED"}}`
svc, _, repo := newGeminiCachedContentTestService(http.StatusForbidden, body)
c, _ := newGeminiNativeTestContext(t)
_, err := svc.ForwardNative(context.Background(), c, geminiDefaultPolicyAPIKeyAccount(),
"gemini-3.8-flash", "generateContent", false, []byte(geminiCachedContentRequestBody))
var failoverErr *UpstreamFailoverError
require.True(t, errors.As(err, &failoverErr), "其他 403 仍按账号问题换号")
require.Equal(t, 1, repo.setErrCalls, "其他 403 仍标记账号错误")
}
// Vertex 对已删除 / 已过期缓存的响应(2026-10 实测):生成请求 400,缓存资源接口 404,文案含上游缓存数字 ID。
const (
geminiVertexCachedContentInvalidStateBody = `{"error":{"code":400,"message":"Invalid resource state for cache content 8079767076522164224.","status":"FAILED_PRECONDITION"}}`
geminiVertexCachedContentNotFoundBody = `{"error":{"code":404,"message":"Cached content 8079767076522164224 is not found.","status":"NOT_FOUND"}}`
)
func TestIsGeminiCachedContentNotFound(t *testing.T) {
require.True(t, isGeminiCachedContentNotFound(http.StatusForbidden, []byte(geminiCachedContentNotFoundBody)))
require.True(t, isGeminiCachedContentNotFound(http.StatusForbidden,
[]byte(`[{"error":{"code":403,"message":"CachedContent not found (or permission denied)","status":"PERMISSION_DENIED"}}]`)))
require.True(t, isGeminiCachedContentNotFound(http.StatusBadRequest, []byte(geminiVertexCachedContentInvalidStateBody)))
require.True(t, isGeminiCachedContentNotFound(http.StatusNotFound, []byte(geminiVertexCachedContentNotFoundBody)))
require.False(t, isGeminiCachedContentNotFound(http.StatusBadRequest, []byte(geminiCachedContentNotFoundBody)))
require.False(t, isGeminiCachedContentNotFound(http.StatusForbidden,
[]byte(`{"error":{"code":403,"message":"Permission denied: Consumer has been suspended.","status":"PERMISSION_DENIED"}}`)))
require.False(t, isGeminiCachedContentNotFound(http.StatusNotFound,
[]byte(`{"error":{"code":404,"message":"Publisher Model `+"`projects/p/locations/global/publishers/google/models/gemini-9`"+` was not found.","status":"NOT_FOUND"}}`)))
require.False(t, isGeminiCachedContentNotFound(http.StatusBadRequest,
[]byte(`{"error":{"code":400,"message":"Tool config, tools and system instruction should not be set in the request when using cached content.","status":"INVALID_ARGUMENT"}}`)))
require.False(t, isGeminiCachedContentNotFound(http.StatusInternalServerError, []byte(geminiVertexCachedContentNotFoundBody)))
}
func TestGeminiForwardNative_VertexCachedContentNotFoundReturnsStandardForbidden(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name string
status int
body string
action string
stream bool
}{
{name: "generate invalid resource state", status: http.StatusBadRequest, body: geminiVertexCachedContentInvalidStateBody, action: "generateContent"},
{name: "stream invalid resource state", status: http.StatusBadRequest, body: geminiVertexCachedContentInvalidStateBody, action: "streamGenerateContent", stream: true},
{name: "generate not found", status: http.StatusNotFound, body: geminiVertexCachedContentNotFoundBody, action: "generateContent"},
} {
t.Run(tc.name, func(t *testing.T) {
svc, httpStub, repo := newGeminiCachedContentTestService(tc.status, tc.body)
svc.tokenProvider = NewGeminiTokenProvider(nil, &fakeGeminiTokenCache{token: "ya29.test-token"}, nil)
c, rec := newGeminiNativeTestContext(t)
account := vertexServiceAccount()
account.ID = 811
result, err := svc.ForwardNative(context.Background(), c, account,
"gemini-3.8-flash", tc.action, tc.stream,
[]byte(`{"cachedContent":"projects/proj-1/locations/global/cachedContents/8079767076522164224","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`))
require.Nil(t, result)
require.Error(t, err)
var failoverErr *UpstreamFailoverError
require.False(t, errors.As(err, &failoverErr), "缓存不存在是请求问题,不应换号")
require.Equal(t, 0, repo.setErrCalls)
require.Equal(t, 0, repo.tempCalls)
require.Empty(t, repo.modelRateLimitCalls, "404 不应按模型不存在记模型限流")
require.Equal(t, 1, httpStub.calls, "不应重试")
require.Equal(t, http.StatusForbidden, rec.Code)
require.Equal(t, GeminiCachedContentNotFoundResponse, rec.Body.String())
require.NotContains(t, rec.Body.String(), "8079767076522164224", "不应暴露上游缓存 ID")
require.True(t, IsOpsRequestScopedError(c), "缓存不存在按客户端请求错误统计")
errType, _ := OpsLocalErrorType(c)
require.Equal(t, "permission_error", errType)
})
}
}