mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:48:43 +08:00
fix(gemini): Skipped 错误策略对齐 OpenAI,上游 4xx 不再硬改 500
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,客户 端可据此定位非法字段
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<<uint(attempt-1))
|
||||
if delay > 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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user