From ab0fcd1a0e6fa8f9974ba21c6c3478bebdd07be4 Mon Sep 17 00:00:00 2001 From: feeeei Date: Mon, 17 Aug 2026 18:49:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(gemini):=20Skipped=20=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E5=AF=B9=E9=BD=90=20OpenAI=EF=BC=8C=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=204xx=20=E4=B8=8D=E5=86=8D=E7=A1=AC=E6=94=B9=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ErrorPolicySkipped(池模式、或自定义错误码未命中)原来在响应写出上 自成一派:v1beta 原生把上游 4xx 硬改 500 后原文透传,/v1/messages 硬传 500 进映射(客户端拿到 502)。下游网关据此把请求级错误当可重 试的服务端故障反复换号,耗尽后改写成 All available accounts exhausted(2026-08-17 gemini 生产事故链)。现对齐 OpenAI 路径语义: - Skipped 只豁免账号状态标记,不豁免换号:可 failover 状态码一律 返回 UpstreamFailoverError(poolModeSkippedFailoverError 泛化为 skippedErrorPolicyFailoverError,同账号重试标记仍仅池模式携带) - 池模式的不可 failover 4xx 保真:v1beta 原码+原文透传(新 writeGeminiNativeUpstreamError 与 ErrorPolicyNone 共用同一写出, 并补记 ops 事件),/v1/messages 与 chat completions 按真实状态 码映射 - 自定义错误码未命中且不可 failover:三路径统一 500 + "Upstream gateway error" 固定文案,上游细节仅记 ops 错误日志 - 400 属确定性请求错误:mapped 写出回传脱敏后的上游 message,客户 端可据此定位非法字段 --- .../gemini_chat_completions_compat_service.go | 12 +- .../gemini_error_policy_skipped_write_test.go | 213 ++++++++++++++++++ .../service/gemini_error_policy_test.go | 48 ++-- .../service/gemini_messages_compat_service.go | 161 ++++++++----- 4 files changed, 360 insertions(+), 74 deletions(-) create mode 100644 backend/internal/service/gemini_error_policy_skipped_write_test.go diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index e76fbcee15..3ee7a4af9e 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -240,6 +240,11 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( } } + if policy == ErrorPolicySkipped && account.IsCustomErrorCodesEnabled() { + return nil, s.writeGeminiCustomCodeSkippedError(c, account, resp.StatusCode, requestID, evBody, func() { + _ = s.writeChatCompletionsError(c, http.StatusInternalServerError, "api_error", geminiCustomCodeSkippedClientMessage) + }) + } return nil, s.writeGeminiChatCompletionsMappedError(c, account, resp.StatusCode, requestID, evBody) } @@ -856,8 +861,13 @@ func (s *GeminiMessagesCompatService) writeGeminiChatCompletionsMappedError( if errType == "upstream_error" { errType = "invalid_request_error" } + // 400 是确定性的请求错误:回传上游 message(已脱敏),客户端据此定位非法字段。 if errMsg == "Upstream request failed" { - errMsg = "Invalid request" + if upstreamMsg != "" { + errMsg = upstreamMsg + } else { + errMsg = "Invalid request" + } } case http.StatusNotFound: statusCode = http.StatusNotFound diff --git a/backend/internal/service/gemini_error_policy_skipped_write_test.go b/backend/internal/service/gemini_error_policy_skipped_write_test.go new file mode 100644 index 0000000000..41c37e24da --- /dev/null +++ b/backend/internal/service/gemini_error_policy_skipped_write_test.go @@ -0,0 +1,213 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// ErrorPolicySkipped 的客户端写出契约(与 OpenAI 网关路径对齐): +// - 池模式:不可 failover 的 4xx 按上游原始状态码/响应体保真写出,不改写成 5xx; +// - 自定义错误码未命中:统一 500 + 固定文案,上游细节只进 ops 错误日志; +// - 可 failover 的状态码(两种账号)一律换号,不透传。 +// --------------------------------------------------------------------------- + +const geminiSkippedTestUpstreamMsg = "antigravity executor: invalid Gemini function call history" + +func geminiSkippedTestUpstreamBody() string { + return `{"error":{"code":null,"message":"` + geminiSkippedTestUpstreamMsg + `","param":"","type":"invalid_request_error"}}` +} + +func newGeminiSkippedWriteService(status int, body string) (*GeminiMessagesCompatService, *geminiCompatHTTPUpstreamStub) { + 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(&errorPolicyRepoStub{}, nil, &config.Config{}, nil, nil), + } + return svc, httpStub +} + +func geminiPoolModeAPIKeyAccount() *Account { + return &Account{ + ID: 700, + Platform: PlatformGemini, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-key", + "pool_mode": true, + }, + } +} + +func geminiCustomCodesAPIKeyAccount() *Account { + return &Account{ + ID: 701, + Platform: PlatformGemini, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-key", + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(429)}, + }, + } +} + +func newGeminiNativeTestContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", strings.NewReader("{}")) + return c, rec +} + +func TestGeminiForwardNative_PoolModeSkipped400PassthroughRealStatus(t *testing.T) { + gin.SetMode(gin.TestMode) + upstreamBody := geminiSkippedTestUpstreamBody() + svc, _ := newGeminiSkippedWriteService(http.StatusBadRequest, upstreamBody) + c, rec := newGeminiNativeTestContext(t) + + result, err := svc.ForwardNative(context.Background(), c, geminiPoolModeAPIKeyAccount(), + "gemini-2.5-flash", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)) + + require.Nil(t, result) + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr), "池模式 400 不应换号") + require.Contains(t, err.Error(), "gemini upstream error: 400") + require.Equal(t, http.StatusBadRequest, rec.Code, "状态码应保真为上游 400") + require.Equal(t, upstreamBody, rec.Body.String(), "响应体应原样透传") +} + +func TestGeminiForwardNative_PoolModeSkipped503Failover(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _ := newGeminiSkippedWriteService(http.StatusServiceUnavailable, `{"error":{"message":"Upstream service temporarily unavailable"}}`) + c, rec := newGeminiNativeTestContext(t) + + result, err := svc.ForwardNative(context.Background(), c, geminiPoolModeAPIKeyAccount(), + "gemini-2.5-flash", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)) + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.True(t, errors.As(err, &failoverErr), "池模式 503 应换号") + require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode) + require.Zero(t, rec.Body.Len(), "换号场景不应写客户端响应") +} + +func TestGeminiForwardNative_CustomCodesMiss400HiddenAs500(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _ := newGeminiSkippedWriteService(http.StatusBadRequest, geminiSkippedTestUpstreamBody()) + c, rec := newGeminiNativeTestContext(t) + + result, err := svc.ForwardNative(context.Background(), c, geminiCustomCodesAPIKeyAccount(), + "gemini-2.5-flash", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)) + + require.Nil(t, result) + require.Error(t, err) + require.Contains(t, err.Error(), "not in custom error codes") + require.Equal(t, http.StatusInternalServerError, rec.Code) + + var got map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + errObj, ok := got["error"].(map[string]any) + require.True(t, ok) + require.Equal(t, geminiCustomCodeSkippedClientMessage, errObj["message"]) + require.NotContains(t, rec.Body.String(), geminiSkippedTestUpstreamMsg, "上游细节不应透传给客户端") +} + +func TestGeminiForwardNative_CustomCodesMiss500Failover(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _ := newGeminiSkippedWriteService(http.StatusInternalServerError, `{"error":{"message":"internal"}}`) + c, rec := newGeminiNativeTestContext(t) + + result, err := svc.ForwardNative(context.Background(), c, geminiCustomCodesAPIKeyAccount(), + "gemini-2.5-flash", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)) + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.True(t, errors.As(err, &failoverErr), "自定义错误码未命中的 500 应换号") + require.Equal(t, http.StatusInternalServerError, failoverErr.StatusCode) + require.False(t, failoverErr.RetryableOnSameAccount, "非池模式不应同账号重试") + require.Zero(t, rec.Body.Len()) +} + +func TestGeminiForwardAsChatCompletions_CustomCodesMiss400HiddenAs500(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _ := newGeminiSkippedWriteService(http.StatusBadRequest, geminiSkippedTestUpstreamBody()) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}]}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(body))) + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiCustomCodesAPIKeyAccount(), body) + + require.Nil(t, result) + require.Error(t, err) + require.Contains(t, err.Error(), "not in custom error codes") + require.Equal(t, http.StatusInternalServerError, rec.Code) + + var got map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + errObj, ok := got["error"].(map[string]any) + require.True(t, ok) + require.Equal(t, "api_error", errObj["type"]) + require.Equal(t, geminiCustomCodeSkippedClientMessage, errObj["message"]) +} + +func TestGeminiForwardAsChatCompletions_PoolMode400KeepsUpstreamMessage(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _ := newGeminiSkippedWriteService(http.StatusBadRequest, geminiSkippedTestUpstreamBody()) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}]}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(body))) + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiPoolModeAPIKeyAccount(), body) + + require.Nil(t, result) + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, rec.Code, "状态码应保真为上游 400") + + var got map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + errObj, ok := got["error"].(map[string]any) + require.True(t, ok) + require.Equal(t, "invalid_request_error", errObj["type"]) + require.Equal(t, geminiSkippedTestUpstreamMsg, errObj["message"], "应回传上游 message") +} + +func TestWriteGeminiMappedError_400KeepsUpstreamMessage(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &GeminiMessagesCompatService{cfg: &config.Config{}} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + err := svc.writeGeminiMappedError(c, &Account{ID: 702, Platform: PlatformGemini}, http.StatusBadRequest, "req-1", []byte(geminiSkippedTestUpstreamBody())) + + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, rec.Code) + var got map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + errObj, ok := got["error"].(map[string]any) + require.True(t, ok) + require.Equal(t, geminiSkippedTestUpstreamMsg, errObj["message"], "应回传上游 message") +} diff --git a/backend/internal/service/gemini_error_policy_test.go b/backend/internal/service/gemini_error_policy_test.go index 46024077a5..807ed7056b 100644 --- a/backend/internal/service/gemini_error_policy_test.go +++ b/backend/internal/service/gemini_error_policy_test.go @@ -218,7 +218,7 @@ func TestGeminiErrorPolicyIntegration(t *testing.T) { expectHandleError: true, }, { - name: "custom_codes_skipped_500_no_failover", + name: "custom_codes_skipped_500_failover", account: &Account{ ID: 201, Type: AccountTypeAPIKey, @@ -230,6 +230,22 @@ func TestGeminiErrorPolicyIntegration(t *testing.T) { }, statusCode: 500, respBody: []byte(`{"error":"internal"}`), + expectFailover: true, + expectHandleError: false, + }, + { + name: "custom_codes_skipped_400_no_failover", + account: &Account{ + ID: 205, + Type: AccountTypeAPIKey, + Platform: PlatformGemini, + Credentials: map[string]any{ + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(429)}, + }, + }, + statusCode: 400, + respBody: []byte(`{"error":"bad request"}`), expectFailover: false, expectHandleError: false, }, @@ -311,9 +327,9 @@ func TestGeminiErrorPolicyIntegration(t *testing.T) { policy := svc.rateLimitService.CheckErrorPolicy(ctx, account, statusCode, respBody, "gemini-2.5-pro") switch policy { case ErrorPolicySkipped: - // Skipped → return error directly (no handleGeminiUpstreamError, no failover) - gotFailover = false + // Skipped → 不标记账号状态;可 failover 的状态码仍换号 handleErrorCalled = false + gotFailover = svc.skippedErrorPolicyFailoverError(c, account, statusCode, respBody, "req-test") != nil goto verify case ErrorPolicyMatched: svc.handleGeminiUpstreamError(ctx, account, statusCode, headers, respBody) @@ -353,12 +369,12 @@ func TestGeminiErrorPolicyIntegration(t *testing.T) { } // --------------------------------------------------------------------------- -// TestPoolModeSkippedFailoverError — pool-mode accounts hitting -// ErrorPolicySkipped must failover (align with other platform forwards) -// instead of passing the upstream error through to the client. +// TestSkippedErrorPolicyFailoverError — ErrorPolicySkipped(池模式、或自定义 +// 错误码未命中)不豁免换号:可 failover 的状态码返回 UpstreamFailoverError, +// 仅池模式账号可携带同账号重试标记。 // --------------------------------------------------------------------------- -func TestPoolModeSkippedFailoverError(t *testing.T) { +func TestSkippedErrorPolicyFailoverError(t *testing.T) { gin.SetMode(gin.TestMode) svc := &GeminiMessagesCompatService{} @@ -369,6 +385,13 @@ func TestPoolModeSkippedFailoverError(t *testing.T) { } return &Account{ID: 300, Type: AccountTypeAPIKey, Platform: PlatformGemini, Credentials: creds} } + customCodesAccount := &Account{ + ID: 301, Type: AccountTypeAPIKey, Platform: PlatformGemini, + Credentials: map[string]any{ + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(429)}, + }, + } tests := []struct { name string @@ -383,13 +406,8 @@ func TestPoolModeSkippedFailoverError(t *testing.T) { "pool_mode_retry_status_codes": []any{float64(500)}, }), 500, true, true}, {"pool_400_not_failover_worthy", poolAccount(nil), 400, false, false}, - {"non_pool_account_keeps_passthrough", &Account{ - ID: 301, Type: AccountTypeAPIKey, Platform: PlatformGemini, - Credentials: map[string]any{ - "custom_error_codes_enabled": true, - "custom_error_codes": []any{float64(429)}, - }, - }, 500, false, false}, + {"custom_codes_miss_500_failover_no_same_account_retry", customCodesAccount, 500, true, false}, + {"custom_codes_miss_400_not_failover_worthy", customCodesAccount, 400, false, false}, } for _, tt := range tests { @@ -399,7 +417,7 @@ func TestPoolModeSkippedFailoverError(t *testing.T) { c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) body := []byte(`{"error":{"code":"bad_response_status_code","message":"openai_error"}}`) - failoverErr := svc.poolModeSkippedFailoverError(c, tt.account, tt.statusCode, body, "req-1") + failoverErr := svc.skippedErrorPolicyFailoverError(c, tt.account, tt.statusCode, body, "req-1") if !tt.expectFailover { require.Nil(t, failoverErr) diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 0abd14937a..82cf9a20f3 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -947,10 +947,16 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex if upstreamReqID == "" { upstreamReqID = resp.Header.Get("x-goog-request-id") } - if failoverErr := s.poolModeSkippedFailoverError(c, account, resp.StatusCode, respBody, upstreamReqID); failoverErr != nil { + if failoverErr := s.skippedErrorPolicyFailoverError(c, account, resp.StatusCode, respBody, upstreamReqID); failoverErr != nil { return nil, failoverErr } - return nil, s.writeGeminiMappedError(c, account, http.StatusInternalServerError, upstreamReqID, respBody) + if account.IsCustomErrorCodesEnabled() { + return nil, s.writeGeminiCustomCodeSkippedError(c, account, resp.StatusCode, upstreamReqID, respBody, func() { + _ = s.writeClaudeError(c, http.StatusInternalServerError, "api_error", geminiCustomCodeSkippedClientMessage) + }) + } + // 池模式:客户端写出与 ErrorPolicyNone 相同(按上游真实状态码映射),仅跳过账号状态标记。 + return nil, s.writeGeminiMappedError(c, account, resp.StatusCode, upstreamReqID, respBody) case ErrorPolicyMatched, ErrorPolicyTempUnscheduled: if policy == ErrorPolicyMatched { s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) @@ -1460,17 +1466,16 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. policy := s.rateLimitService.CheckErrorPolicy(ctx, account, resp.StatusCode, respBody, mappedModel) switch policy { case ErrorPolicySkipped: - if failoverErr := s.poolModeSkippedFailoverError(c, account, resp.StatusCode, respBody, requestID); failoverErr != nil { + if failoverErr := s.skippedErrorPolicyFailoverError(c, account, resp.StatusCode, respBody, requestID); failoverErr != nil { return nil, failoverErr } - respBody = unwrapIfNeeded(isOAuth, respBody) - contentType := resp.Header.Get("Content-Type") - if contentType == "" { - contentType = "application/json" + if account.IsCustomErrorCodesEnabled() { + return nil, s.writeGeminiCustomCodeSkippedError(c, account, resp.StatusCode, requestID, respBody, func() { + _ = s.writeGoogleError(c, http.StatusInternalServerError, geminiCustomCodeSkippedClientMessage) + }) } - MarkResponseCommitted(c) - c.Data(http.StatusInternalServerError, contentType, respBody) - return nil, fmt.Errorf("gemini upstream error: %d (skipped by error policy)", resp.StatusCode) + // 池模式:客户端写出与 ErrorPolicyNone 相同(状态码/响应体保真),仅跳过账号状态标记。 + return nil, s.writeGeminiNativeUpstreamError(c, account, resp, respBody, requestID, isOAuth) case ErrorPolicyMatched, ErrorPolicyTempUnscheduled: if policy == ErrorPolicyMatched { s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) @@ -1555,40 +1560,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: evBody} } - respBody = unwrapIfNeeded(isOAuth, respBody) - upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - upstreamDetail := "" - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes - if maxBytes <= 0 { - maxBytes = 2048 - } - upstreamDetail = truncateString(string(respBody), maxBytes) - logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini] native upstream error %d: %s", resp.StatusCode, truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)) - } - setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: requestID, - Kind: "http_error", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - - contentType := resp.Header.Get("Content-Type") - if contentType == "" { - contentType = "application/json" - } - MarkResponseCommitted(c) - c.Data(resp.StatusCode, contentType, respBody) - if upstreamMsg == "" { - return nil, fmt.Errorf("gemini upstream error: %d", resp.StatusCode) - } - return nil, fmt.Errorf("gemini upstream error: %d message=%s", resp.StatusCode, upstreamMsg) + return nil, s.writeGeminiNativeUpstreamError(c, account, resp, respBody, requestID, isOAuth) } var usage *ClaudeUsage @@ -1695,22 +1667,16 @@ func (s *GeminiMessagesCompatService) shouldFailoverGeminiUpstreamError(statusCo } } -// poolModeSkippedFailoverError 池模式账号命中 ErrorPolicySkipped 时构造 failover 错误: -// 可 failover 的状态码返回 UpstreamFailoverError,交给 handler 层按 pool_mode_retry_count -// 同账号重试后换号;返回 nil 表示不适用(非池模式或状态码不可 failover),由调用方透传。 -func (s *GeminiMessagesCompatService) poolModeSkippedFailoverError(c *gin.Context, account *Account, statusCode int, respBody []byte, upstreamRequestID string) *UpstreamFailoverError { - if !account.IsPoolMode() || !s.shouldFailoverGeminiUpstreamError(statusCode) { +// skippedErrorPolicyFailoverError 命中 ErrorPolicySkipped(池模式、或自定义错误码未命中) +// 时构造 failover 错误:可 failover 的状态码返回 UpstreamFailoverError,交给 handler 层换号 +// (池模式账号按 pool_mode_retry_count 先同账号重试);返回 nil 表示状态码不可 failover, +// 由调用方决定客户端写出。Skipped 只豁免账号状态标记,不豁免换号,与 OpenAI 网关路径一致。 +func (s *GeminiMessagesCompatService) skippedErrorPolicyFailoverError(c *gin.Context, account *Account, statusCode int, respBody []byte, upstreamRequestID string) *UpstreamFailoverError { + if !s.shouldFailoverGeminiUpstreamError(statusCode) { return nil } upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) - upstreamDetail := "" - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes - if maxBytes <= 0 { - maxBytes = 2048 - } - upstreamDetail = truncateString(string(respBody), maxBytes) - } + upstreamDetail := s.upstreamErrorDetail(respBody) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ Platform: account.Platform, AccountID: account.ID, @@ -1724,10 +1690,85 @@ func (s *GeminiMessagesCompatService) poolModeSkippedFailoverError(c *gin.Contex return &UpstreamFailoverError{ StatusCode: statusCode, ResponseBody: respBody, - RetryableOnSameAccount: account.IsPoolModeRetryableStatus(statusCode), + RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode), } } +// geminiCustomCodeSkippedClientMessage 自定义错误码未命中时对客户端隐藏上游细节的固定文案, +// 与 OpenAI 网关路径同场景的文案一致。 +const geminiCustomCodeSkippedClientMessage = "Upstream gateway error" + +// upstreamErrorDetail 按配置截断上游错误响应体,用于 ops 错误日志的 Detail 字段; +// 未开启 LogUpstreamErrorBody 时返回空。 +func (s *GeminiMessagesCompatService) upstreamErrorDetail(body []byte) string { + if s.cfg == nil || !s.cfg.Gateway.LogUpstreamErrorBody { + return "" + } + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + return truncateString(string(body), maxBytes) +} + +// writeGeminiCustomCodeSkippedError 处理自定义错误码未命中且不可 failover 的上游错误: +// 客户端统一收到 500 + 固定文案(由 write 按端点格式写出),不透传上游细节; +// 上游真实状态码与错误信息仅记录到 ops 错误日志。 +func (s *GeminiMessagesCompatService) writeGeminiCustomCodeSkippedError(c *gin.Context, account *Account, upstreamStatus int, upstreamRequestID string, body []byte, write func()) error { + upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(body))) + upstreamDetail := s.upstreamErrorDetail(body) + setOpsUpstreamError(c, upstreamStatus, upstreamMsg, upstreamDetail) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: upstreamStatus, + UpstreamRequestID: upstreamRequestID, + Kind: "http_error", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + write() + if upstreamMsg == "" { + return fmt.Errorf("gemini upstream error: %d (not in custom error codes)", upstreamStatus) + } + return fmt.Errorf("gemini upstream error: %d (not in custom error codes) message=%s", upstreamStatus, upstreamMsg) +} + +// writeGeminiNativeUpstreamError 将不可 failover 的上游错误按原始状态码与响应体透传给客户端, +// 并记录 ops 错误事件。状态码保真:下游据此区分请求级错误与可重试的链路故障。 +func (s *GeminiMessagesCompatService) writeGeminiNativeUpstreamError(c *gin.Context, account *Account, resp *http.Response, respBody []byte, requestID string, isOAuth bool) error { + respBody = unwrapIfNeeded(isOAuth, respBody) + upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + upstreamDetail := s.upstreamErrorDetail(respBody) + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini] native upstream error %d: %s", resp.StatusCode, truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)) + } + setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: requestID, + Kind: "http_error", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/json" + } + MarkResponseCommitted(c) + c.Data(resp.StatusCode, contentType, respBody) + if upstreamMsg == "" { + return fmt.Errorf("gemini upstream error: %d", resp.StatusCode) + } + return fmt.Errorf("gemini upstream error: %d message=%s", resp.StatusCode, upstreamMsg) +} + func sleepGeminiBackoff(attempt int) { delay := geminiRetryBaseDelay * time.Duration(1< geminiRetryMaxDelay { @@ -1827,6 +1868,10 @@ func (s *GeminiMessagesCompatService) writeGeminiMappedError(c *gin.Context, acc if errType == "" { errType = "invalid_request_error" } + // 400 是确定性的请求错误:回传上游 message(已脱敏),客户端据此定位非法字段。 + if errMsg == "" { + errMsg = upstreamMsg + } if errMsg == "" { errMsg = "Invalid request" }