| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130 |
- package service
- import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "go.uber.org/zap"
- )
- const openAIAPIKeyHealthBreakerReason = "openai_apikey_health_breaker"
- func isOpenAIAPIKeyHealthBreakerAccount(account *Account) bool {
- return account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey && account.IsPoolMode()
- }
- func classifyOpenAIAPIKeyHealthFailure(err error) (int, []byte, bool) {
- if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
- return 0, nil, false
- }
- var failoverErr *UpstreamFailoverError
- if errors.As(err, &failoverErr) {
- // These failures already have dedicated recovery/state handling or are not
- // attributable to the selected account.
- if failoverErr.IsCredentialFailure() ||
- failoverErr.RequestScopedTransient ||
- failoverErr.RetryableOnSameAccount ||
- failoverErr.Scope == GatewayFailureScopeRequest ||
- failoverErr.Scope == GatewayFailureScopeProvider {
- return failoverErr.StatusCode, failoverErr.ResponseBody, false
- }
- if failoverErr.StatusCode == http.StatusTooManyRequests || failoverErr.StatusCode >= http.StatusInternalServerError {
- return failoverErr.StatusCode, failoverErr.ResponseBody, true
- }
- return failoverErr.StatusCode, failoverErr.ResponseBody, false
- }
- var imageErr *OpenAIImagesUpstreamError
- if errors.As(err, &imageErr) {
- if imageErr.StatusCode == http.StatusTooManyRequests || imageErr.StatusCode >= http.StatusInternalServerError {
- return imageErr.StatusCode, []byte(strings.TrimSpace(imageErr.Message)), true
- }
- }
- return 0, nil, false
- }
- func (s *RateLimitService) ObserveOpenAIAPIKeyHealthFailure(ctx context.Context, account *Account, upstreamErr error) bool {
- if s == nil || s.openAIAPIKeyHealth == nil || s.settingService == nil || s.accountRepo == nil || !isOpenAIAPIKeyHealthBreakerAccount(account) {
- return false
- }
- statusCode, responseBody, eligible := classifyOpenAIAPIKeyHealthFailure(upstreamErr)
- if !eligible {
- return false
- }
- settings, err := s.settingService.GetOpenAIAPIKeyHealthBreakerSettings(ctx)
- if err != nil {
- logger.L().Warn("openai.apikey_health_breaker_settings_failed", zap.Int64("account_id", account.ID), zap.Error(err))
- return false
- }
- if settings == nil || !settings.Enabled {
- return false
- }
- count, tripped, err := s.openAIAPIKeyHealth.RecordOpenAIAPIKeyHealthFailure(ctx, account.ID, settings.WindowMinutes, settings.FailureThreshold)
- if err != nil {
- logger.L().Warn("openai.apikey_health_breaker_record_failed", zap.Int64("account_id", account.ID), zap.Error(err))
- return false
- }
- if !tripped {
- return false
- }
- now := time.Now()
- until := now.Add(time.Duration(settings.CooldownMinutes) * time.Minute)
- state := &TempUnschedState{
- UntilUnix: until.Unix(),
- TriggeredAtUnix: now.Unix(),
- StatusCode: statusCode,
- MatchedKeyword: openAIAPIKeyHealthBreakerReason,
- RuleIndex: -1,
- ErrorMessage: truncateTempUnschedMessage(responseBody, tempUnschedMessageMaxBytes),
- TriggerCount: count,
- TriggerThreshold: settings.FailureThreshold,
- TriggerWindowMinutes: settings.WindowMinutes,
- }
- reasonBytes, _ := json.Marshal(state)
- reason := string(reasonBytes)
- if reason == "" {
- reason = fmt.Sprintf("%s: %d failures in %d minute(s)", openAIAPIKeyHealthBreakerReason, count, settings.WindowMinutes)
- }
- persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second)
- defer cancel()
- if err := s.accountRepo.SetTempUnschedulable(persistCtx, account.ID, until, reason); err != nil {
- logger.L().Warn("openai.apikey_health_breaker_persist_failed", zap.Int64("account_id", account.ID), zap.Error(err))
- return false
- }
- if account.TempUnschedulableUntil == nil || account.TempUnschedulableUntil.Before(until) {
- account.TempUnschedulableUntil = &until
- account.TempUnschedulableReason = reason
- }
- s.notifyAccountSchedulingBlocked(account, until, openAIAPIKeyHealthBreakerReason)
- if s.tempUnschedCache != nil {
- if err := s.tempUnschedCache.SetTempUnsched(persistCtx, account.ID, state); err != nil {
- logger.L().Warn("openai.apikey_health_breaker_cache_failed", zap.Int64("account_id", account.ID), zap.Error(err))
- }
- }
- logger.L().Warn("openai.apikey_health_breaker_tripped",
- zap.Int64("account_id", account.ID),
- zap.Int64("failure_count", count),
- zap.Int("failure_threshold", settings.FailureThreshold),
- zap.Int("window_minutes", settings.WindowMinutes),
- zap.Int("cooldown_minutes", settings.CooldownMinutes),
- zap.Int("upstream_status", statusCode),
- zap.Time("until", until),
- )
- return true
- }
- func (s *RateLimitService) ObserveOpenAIAPIKeyHealthSuccess(context.Context, *Account) {
- // Health failures are accumulated in a rolling time window. A success does
- // not reset that window and must not add a Redis round trip to the hot path.
- }
|