openai_apikey_health_breaker.go 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "net/http"
  8. "strings"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  11. "go.uber.org/zap"
  12. )
  13. const openAIAPIKeyHealthBreakerReason = "openai_apikey_health_breaker"
  14. func isOpenAIAPIKeyHealthBreakerAccount(account *Account) bool {
  15. return account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey && account.IsPoolMode()
  16. }
  17. func classifyOpenAIAPIKeyHealthFailure(err error) (int, []byte, bool) {
  18. if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
  19. return 0, nil, false
  20. }
  21. var failoverErr *UpstreamFailoverError
  22. if errors.As(err, &failoverErr) {
  23. // These failures already have dedicated recovery/state handling or are not
  24. // attributable to the selected account.
  25. if failoverErr.IsCredentialFailure() ||
  26. failoverErr.RequestScopedTransient ||
  27. failoverErr.RetryableOnSameAccount ||
  28. failoverErr.Scope == GatewayFailureScopeRequest ||
  29. failoverErr.Scope == GatewayFailureScopeProvider {
  30. return failoverErr.StatusCode, failoverErr.ResponseBody, false
  31. }
  32. if failoverErr.StatusCode == http.StatusTooManyRequests || failoverErr.StatusCode >= http.StatusInternalServerError {
  33. return failoverErr.StatusCode, failoverErr.ResponseBody, true
  34. }
  35. return failoverErr.StatusCode, failoverErr.ResponseBody, false
  36. }
  37. var imageErr *OpenAIImagesUpstreamError
  38. if errors.As(err, &imageErr) {
  39. if imageErr.StatusCode == http.StatusTooManyRequests || imageErr.StatusCode >= http.StatusInternalServerError {
  40. return imageErr.StatusCode, []byte(strings.TrimSpace(imageErr.Message)), true
  41. }
  42. }
  43. return 0, nil, false
  44. }
  45. func (s *RateLimitService) ObserveOpenAIAPIKeyHealthFailure(ctx context.Context, account *Account, upstreamErr error) bool {
  46. if s == nil || s.openAIAPIKeyHealth == nil || s.settingService == nil || s.accountRepo == nil || !isOpenAIAPIKeyHealthBreakerAccount(account) {
  47. return false
  48. }
  49. statusCode, responseBody, eligible := classifyOpenAIAPIKeyHealthFailure(upstreamErr)
  50. if !eligible {
  51. return false
  52. }
  53. settings, err := s.settingService.GetOpenAIAPIKeyHealthBreakerSettings(ctx)
  54. if err != nil {
  55. logger.L().Warn("openai.apikey_health_breaker_settings_failed", zap.Int64("account_id", account.ID), zap.Error(err))
  56. return false
  57. }
  58. if settings == nil || !settings.Enabled {
  59. return false
  60. }
  61. count, tripped, err := s.openAIAPIKeyHealth.RecordOpenAIAPIKeyHealthFailure(ctx, account.ID, settings.WindowMinutes, settings.FailureThreshold)
  62. if err != nil {
  63. logger.L().Warn("openai.apikey_health_breaker_record_failed", zap.Int64("account_id", account.ID), zap.Error(err))
  64. return false
  65. }
  66. if !tripped {
  67. return false
  68. }
  69. now := time.Now()
  70. until := now.Add(time.Duration(settings.CooldownMinutes) * time.Minute)
  71. state := &TempUnschedState{
  72. UntilUnix: until.Unix(),
  73. TriggeredAtUnix: now.Unix(),
  74. StatusCode: statusCode,
  75. MatchedKeyword: openAIAPIKeyHealthBreakerReason,
  76. RuleIndex: -1,
  77. ErrorMessage: truncateTempUnschedMessage(responseBody, tempUnschedMessageMaxBytes),
  78. TriggerCount: count,
  79. TriggerThreshold: settings.FailureThreshold,
  80. TriggerWindowMinutes: settings.WindowMinutes,
  81. }
  82. reasonBytes, _ := json.Marshal(state)
  83. reason := string(reasonBytes)
  84. if reason == "" {
  85. reason = fmt.Sprintf("%s: %d failures in %d minute(s)", openAIAPIKeyHealthBreakerReason, count, settings.WindowMinutes)
  86. }
  87. persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second)
  88. defer cancel()
  89. if err := s.accountRepo.SetTempUnschedulable(persistCtx, account.ID, until, reason); err != nil {
  90. logger.L().Warn("openai.apikey_health_breaker_persist_failed", zap.Int64("account_id", account.ID), zap.Error(err))
  91. return false
  92. }
  93. if account.TempUnschedulableUntil == nil || account.TempUnschedulableUntil.Before(until) {
  94. account.TempUnschedulableUntil = &until
  95. account.TempUnschedulableReason = reason
  96. }
  97. s.notifyAccountSchedulingBlocked(account, until, openAIAPIKeyHealthBreakerReason)
  98. if s.tempUnschedCache != nil {
  99. if err := s.tempUnschedCache.SetTempUnsched(persistCtx, account.ID, state); err != nil {
  100. logger.L().Warn("openai.apikey_health_breaker_cache_failed", zap.Int64("account_id", account.ID), zap.Error(err))
  101. }
  102. }
  103. logger.L().Warn("openai.apikey_health_breaker_tripped",
  104. zap.Int64("account_id", account.ID),
  105. zap.Int64("failure_count", count),
  106. zap.Int("failure_threshold", settings.FailureThreshold),
  107. zap.Int("window_minutes", settings.WindowMinutes),
  108. zap.Int("cooldown_minutes", settings.CooldownMinutes),
  109. zap.Int("upstream_status", statusCode),
  110. zap.Time("until", until),
  111. )
  112. return true
  113. }
  114. func (s *RateLimitService) ObserveOpenAIAPIKeyHealthSuccess(context.Context, *Account) {
  115. // Health failures are accumulated in a rolling time window. A success does
  116. // not reset that window and must not add a Redis round trip to the hot path.
  117. }