From 69305a6091329369699720f253c60c6bf8b0fcf8 Mon Sep 17 00:00:00 2001 From: benjamin Date: Wed, 20 May 2026 22:01:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(ops):=20=E6=8E=92=E9=99=A4=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=AE=A2=E6=88=B7=E7=AB=AF=E9=99=90=E5=88=B6=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E7=9A=84=20SLA=20=E8=AE=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/internal/handler/ops_error_logger.go | 70 +++-- .../internal/handler/ops_error_logger_test.go | 247 ++++++++++++++++-- 2 files changed, 287 insertions(+), 30 deletions(-) diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index 1d34a062..161df133 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -41,13 +41,18 @@ const ( opsErrInsufficientQuota = "insufficient_quota" // 上游错误码常量 — 错误分类 (normalizeOpsErrorType / classifyOpsPhase / classifyOpsIsBusinessLimited) - opsCodeInsufficientBalance = "INSUFFICIENT_BALANCE" - opsCodeUsageLimitExceeded = "USAGE_LIMIT_EXCEEDED" - opsCodeSubscriptionNotFound = "SUBSCRIPTION_NOT_FOUND" - opsCodeSubscriptionInvalid = "SUBSCRIPTION_INVALID" - opsCodeUserInactive = "USER_INACTIVE" - opsCodeInvalidAPIKey = "INVALID_API_KEY" - opsCodeAPIKeyRequired = "API_KEY_REQUIRED" + opsCodeInsufficientBalance = "INSUFFICIENT_BALANCE" + opsCodeUsageLimitExceeded = "USAGE_LIMIT_EXCEEDED" + opsCodeSubscriptionNotFound = "SUBSCRIPTION_NOT_FOUND" + opsCodeSubscriptionInvalid = "SUBSCRIPTION_INVALID" + opsCodeUserInactive = "USER_INACTIVE" + opsCodeInvalidAPIKey = "INVALID_API_KEY" + opsCodeAPIKeyRequired = "API_KEY_REQUIRED" + opsCodeAPIKeyExpired = "API_KEY_EXPIRED" + opsCodeAPIKeyDisabled = "API_KEY_DISABLED" + opsCodeUserNotFound = "USER_NOT_FOUND" + opsCodeAPIKeyQuotaExhausted = "API_KEY_QUOTA_EXHAUSTED" + opsCodeAPIKeyQueryDeprecated = "api_key_in_query_deprecated" ) const ( @@ -1089,8 +1094,7 @@ func classifyOpsPhase(errType, message, code string) string { if isOpsClientAuthError(code, msg) { return "auth" } - switch strings.TrimSpace(code) { - case opsCodeInsufficientBalance, opsCodeUsageLimitExceeded, opsCodeSubscriptionNotFound, opsCodeSubscriptionInvalid: + if isOpsLocalBusinessLimitError(code, msg) { return "request" } @@ -1149,8 +1153,10 @@ func classifyOpsErrorLog(c *gin.Context, errType, message, code string, status i if routingCapacityLimited { phase = "routing" } - localClientAuthError := !upstreamError && phase == "auth" && isOpsClientAuthError(code, strings.ToLower(message)) - isBusinessLimited = routingCapacityLimited || clientBusinessLimited || classifyOpsIsBusinessLimited(errType, phase, code, status, message, localClientAuthError) + msg := strings.ToLower(message) + localClientAuthError := !upstreamError && phase == "auth" && isOpsClientAuthError(code, msg) + localBusinessLimited := !upstreamError && classifyOpsIsBusinessLimited(errType, phase, code, status, message, localClientAuthError) + isBusinessLimited = routingCapacityLimited || (clientBusinessLimited && !upstreamError) || localBusinessLimited errorOwner = classifyOpsErrorOwner(phase, message) errorSource = classifyOpsErrorSource(phase, message) return phase, isBusinessLimited, errorOwner, errorSource @@ -1160,8 +1166,7 @@ func classifyOpsIsBusinessLimited(errType, phase, code string, status int, messa if len(localClientAuthError) > 0 && localClientAuthError[0] { return true } - switch strings.TrimSpace(code) { - case opsCodeInsufficientBalance, opsCodeUsageLimitExceeded, opsCodeSubscriptionNotFound, opsCodeSubscriptionInvalid, opsCodeUserInactive: + if isOpsLocalBusinessLimitError(code, strings.ToLower(message)) { return true } if phase == "billing" || phase == "concurrency" { @@ -1178,10 +1183,45 @@ func classifyOpsIsBusinessLimited(errType, phase, code string, status int, messa func isOpsClientAuthError(code string, msg string) bool { switch strings.TrimSpace(code) { - case opsCodeInvalidAPIKey, opsCodeAPIKeyRequired: + case opsCodeInvalidAPIKey, + opsCodeAPIKeyRequired, + opsCodeAPIKeyExpired, + opsCodeAPIKeyDisabled, + opsCodeUserNotFound, + opsCodeUserInactive: return true } - return strings.Contains(msg, "invalid api key") || strings.Contains(msg, "api key is required") + return strings.Contains(msg, "invalid api key") || + strings.Contains(msg, "api key is required") || + strings.Contains(msg, "api key is disabled") || + strings.Contains(msg, "user associated with api key not found") || + strings.Contains(msg, "user account is not active") +} + +func isOpsLocalBusinessLimitError(code string, msg string) bool { + switch strings.TrimSpace(code) { + case opsCodeInsufficientBalance, + opsCodeUsageLimitExceeded, + opsCodeSubscriptionNotFound, + opsCodeSubscriptionInvalid, + opsCodeAPIKeyQuotaExhausted, + opsCodeAPIKeyQueryDeprecated: + return true + } + return strings.Contains(msg, "api key in query parameter is deprecated") || + strings.Contains(msg, "query parameter api_key is deprecated") || + strings.Contains(msg, "no active subscription found for this group") || + strings.Contains(msg, opsErrInsufficientBalance) || + strings.Contains(msg, "insufficient account balance") || + strings.Contains(msg, "api key group platform is not gemini") || + strings.Contains(msg, "api key 额度已用完") || + strings.Contains(msg, "api key 5小时限额已用完") || + strings.Contains(msg, "api key 日限额已用完") || + strings.Contains(msg, "api key 7天限额已用完") || + strings.Contains(msg, "daily usage limit exceeded") || + strings.Contains(msg, "weekly usage limit exceeded") || + strings.Contains(msg, "monthly usage limit exceeded") || + strings.Contains(msg, "requests-per-minute limit exceeded") } func hasOpsUpstreamErrorContext(c *gin.Context) bool { diff --git a/backend/internal/handler/ops_error_logger_test.go b/backend/internal/handler/ops_error_logger_test.go index 99a9af2f..81f12b0c 100644 --- a/backend/internal/handler/ops_error_logger_test.go +++ b/backend/internal/handler/ops_error_logger_test.go @@ -260,6 +260,34 @@ func TestClassifyOpsAuthClientErrorsExcludedFromSLA(t *testing.T) { code: "API_KEY_REQUIRED", status: http.StatusUnauthorized, }, + { + name: "expired local API key", + errType: "api_error", + message: "API key 已过期", + code: "API_KEY_EXPIRED", + status: http.StatusForbidden, + }, + { + name: "disabled local API key", + errType: "api_error", + message: "API key is disabled", + code: "API_KEY_DISABLED", + status: http.StatusUnauthorized, + }, + { + name: "local API key user missing", + errType: "api_error", + message: "User associated with API key not found", + code: "USER_NOT_FOUND", + status: http.StatusUnauthorized, + }, + { + name: "inactive local API key user", + errType: "api_error", + message: "User account is not active", + code: "USER_INACTIVE", + status: http.StatusUnauthorized, + }, { name: "google invalid API key", errType: "api_error", @@ -274,6 +302,27 @@ func TestClassifyOpsAuthClientErrorsExcludedFromSLA(t *testing.T) { code: "401", status: http.StatusUnauthorized, }, + { + name: "google disabled API key", + errType: "api_error", + message: "API key is disabled", + code: "401", + status: http.StatusUnauthorized, + }, + { + name: "google local API key user missing", + errType: "api_error", + message: "User associated with API key not found", + code: "401", + status: http.StatusUnauthorized, + }, + { + name: "google inactive local API key user", + errType: "api_error", + message: "User account is not active", + code: "401", + status: http.StatusUnauthorized, + }, } for _, tt := range tests { @@ -294,6 +343,126 @@ func TestClassifyOpsAuthClientErrorsExcludedFromSLA(t *testing.T) { } } +func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) { + tests := []struct { + name string + errType string + message string + code string + status int + wantErrType string + wantPhase string + }{ + { + name: "standard API key quota exhausted", + errType: "api_error", + message: "API key 额度已用完", + code: "API_KEY_QUOTA_EXHAUSTED", + status: http.StatusTooManyRequests, + wantErrType: "api_error", + wantPhase: "request", + }, + { + name: "standard query API key deprecated", + errType: "api_error", + message: "API key in query parameter is deprecated. Please use Authorization header instead.", + code: "api_key_in_query_deprecated", + status: http.StatusBadRequest, + wantErrType: "api_error", + wantPhase: "request", + }, + { + name: "google query API key deprecated", + errType: "api_error", + message: "Query parameter api_key is deprecated. Use Authorization header or key instead.", + code: "400", + status: http.StatusBadRequest, + wantErrType: "api_error", + wantPhase: "request", + }, + { + name: "google no active subscription", + errType: "api_error", + message: "No active subscription found for this group", + code: "403", + status: http.StatusForbidden, + wantErrType: "api_error", + wantPhase: "request", + }, + { + name: "google insufficient account balance", + errType: "api_error", + message: "Insufficient account balance", + code: "403", + status: http.StatusForbidden, + wantErrType: "api_error", + wantPhase: "request", + }, + { + name: "gateway billing cache insufficient balance", + errType: "billing_error", + message: "insufficient balance", + code: "", + status: http.StatusForbidden, + wantErrType: "billing_error", + wantPhase: "request", + }, + { + name: "gemini group platform mismatch", + errType: "api_error", + message: "API key group platform is not gemini", + code: "400", + status: http.StatusBadRequest, + wantErrType: "api_error", + wantPhase: "request", + }, + { + name: "gateway API key 5h rate limit", + errType: "api_error", + message: "api key 5小时限额已用完", + code: "rate_limit_exceeded", + status: http.StatusTooManyRequests, + wantErrType: "api_error", + wantPhase: "request", + }, + { + name: "gateway group RPM limit", + errType: "api_error", + message: "group requests-per-minute limit exceeded", + code: "rate_limit_exceeded", + status: http.StatusTooManyRequests, + wantErrType: "api_error", + wantPhase: "request", + }, + { + name: "google subscription daily limit", + errType: "api_error", + message: "daily usage limit exceeded", + code: "429", + status: http.StatusTooManyRequests, + wantErrType: "api_error", + wantPhase: "request", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + + errType := normalizeOpsErrorType(tt.errType, tt.code) + phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, tt.message, tt.code, tt.status) + + require.Equal(t, tt.wantErrType, errType) + require.Equal(t, tt.wantPhase, phase) + require.True(t, isBusinessLimited) + require.Equal(t, "client", errorOwner) + require.Equal(t, "client_request", errorSource) + }) + } +} + func TestClassifyOpsIPRestrictionAccessDeniedExcludedFromSLA(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() @@ -372,23 +541,71 @@ func TestClassifyOpsUnmarkedNoAvailableTextStillCountsForSLA(t *testing.T) { } func TestClassifyOpsUpstreamAuthTextStillCountsForSLA(t *testing.T) { - gin.SetMode(gin.TestMode) - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - service.SetOpsUpstreamError(c, http.StatusUnauthorized, "Invalid API key", "") + tests := []struct { + name string + message string + code string + status int + }{ + { + name: "invalid API key", + message: "Invalid API key", + code: "401", + status: http.StatusUnauthorized, + }, + { + name: "disabled API key", + message: "API key is disabled", + code: "API_KEY_DISABLED", + status: http.StatusUnauthorized, + }, + { + name: "gemini group platform mismatch", + message: "API key group platform is not gemini", + code: "400", + status: http.StatusBadRequest, + }, + { + name: "provider balance error", + message: "Insufficient account balance", + code: "INSUFFICIENT_BALANCE", + status: http.StatusForbidden, + }, + { + name: "provider subscription error", + message: "No active subscription found for this group", + code: "SUBSCRIPTION_NOT_FOUND", + status: http.StatusForbidden, + }, + { + name: "provider quota error", + message: "api key 额度已用完", + code: "API_KEY_QUOTA_EXHAUSTED", + status: http.StatusTooManyRequests, + }, + } - phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog( - c, - "api_error", - "Invalid API key", - "401", - http.StatusUnauthorized, - ) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + service.SetOpsUpstreamError(c, tt.status, tt.message, "") - require.Equal(t, "upstream", phase) - require.False(t, isBusinessLimited) - require.Equal(t, "provider", errorOwner) - require.Equal(t, "upstream_http", errorSource) + phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog( + c, + "api_error", + tt.message, + tt.code, + tt.status, + ) + + require.Equal(t, "upstream", phase) + require.False(t, isBusinessLimited) + require.Equal(t, "provider", errorOwner) + require.Equal(t, "upstream_http", errorSource) + }) + } } func TestClassifyOpsUpstreamNoAvailableTextStillCountsForSLA(t *testing.T) {