openai_apikey_health_breaker_test.go 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "net/http"
  6. "testing"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/config"
  9. "github.com/stretchr/testify/require"
  10. )
  11. type openAIAPIKeyHealthSettingRepo struct {
  12. SettingRepository
  13. value string
  14. getCalls int
  15. }
  16. func (r *openAIAPIKeyHealthSettingRepo) GetValue(context.Context, string) (string, error) {
  17. r.getCalls++
  18. return r.value, nil
  19. }
  20. type openAIAPIKeyHealthAccountRepo struct {
  21. AccountRepository
  22. setCalls int
  23. reason string
  24. }
  25. func (r *openAIAPIKeyHealthAccountRepo) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, reason string) error {
  26. r.setCalls++
  27. r.reason = reason
  28. return nil
  29. }
  30. type openAIAPIKeyHealthCacheStub struct {
  31. TempUnschedCache
  32. recordCalls int
  33. setCalls int
  34. tripped bool
  35. }
  36. func (c *openAIAPIKeyHealthCacheStub) RecordOpenAIAPIKeyHealthFailure(context.Context, int64, int, int) (int64, bool, error) {
  37. c.recordCalls++
  38. return 3, c.tripped, nil
  39. }
  40. func (c *openAIAPIKeyHealthCacheStub) SetTempUnsched(context.Context, int64, *TempUnschedState) error {
  41. c.setCalls++
  42. return nil
  43. }
  44. type openAIAPIKeyHealthRuntimeBlocker struct{ calls int }
  45. func (b *openAIAPIKeyHealthRuntimeBlocker) BlockAccountScheduling(*Account, time.Time, string) {
  46. b.calls++
  47. }
  48. func (*openAIAPIKeyHealthRuntimeBlocker) ClearAccountSchedulingBlock(int64) {}
  49. func openAIHealthPoolAccount() *Account {
  50. return &Account{
  51. ID: 42,
  52. Platform: PlatformOpenAI,
  53. Type: AccountTypeAPIKey,
  54. Credentials: map[string]any{
  55. "pool_mode": true,
  56. },
  57. }
  58. }
  59. func TestClassifyOpenAIAPIKeyHealthFailureExclusions(t *testing.T) {
  60. tests := []struct {
  61. name string
  62. err error
  63. eligible bool
  64. }{
  65. {name: "account attributed 502", err: &UpstreamFailoverError{StatusCode: http.StatusBadGateway}, eligible: true},
  66. {name: "request scoped capacity", err: &UpstreamFailoverError{StatusCode: 529, RequestScopedTransient: true}},
  67. {name: "provider scoped overload", err: &UpstreamFailoverError{StatusCode: 529, Scope: GatewayFailureScopeProvider}},
  68. {name: "dedicated same account retry", err: &UpstreamFailoverError{StatusCode: http.StatusTooManyRequests, RetryableOnSameAccount: true}},
  69. {name: "credential disable path", err: &UpstreamFailoverError{StatusCode: http.StatusUnauthorized, Stage: GatewayFailureStageAccountAuth, Scope: GatewayFailureScopeAccount}},
  70. {name: "client request", err: &UpstreamFailoverError{StatusCode: http.StatusBadRequest}},
  71. }
  72. for _, tt := range tests {
  73. t.Run(tt.name, func(t *testing.T) {
  74. _, _, eligible := classifyOpenAIAPIKeyHealthFailure(tt.err)
  75. require.Equal(t, tt.eligible, eligible)
  76. })
  77. }
  78. }
  79. func TestOpenAIAPIKeyHealthBreakerDefaultDisabled(t *testing.T) {
  80. settings := NewSettingService(&openAIAPIKeyHealthSettingRepo{}, &config.Config{})
  81. cache := &openAIAPIKeyHealthCacheStub{tripped: true}
  82. svc := NewRateLimitService(&openAIAPIKeyHealthAccountRepo{}, nil, &config.Config{}, nil, cache)
  83. svc.SetSettingService(settings)
  84. svc.SetOpenAIAPIKeyHealthCache(cache)
  85. require.False(t, svc.ObserveOpenAIAPIKeyHealthFailure(context.Background(), openAIHealthPoolAccount(), &UpstreamFailoverError{StatusCode: http.StatusBadGateway}))
  86. require.Zero(t, cache.recordCalls)
  87. }
  88. func TestOpenAIAPIKeyHealthBreakerTripsPersistedAndRuntimeState(t *testing.T) {
  89. encoded, err := json.Marshal(OpenAIAPIKeyHealthBreakerSettings{Enabled: true, WindowMinutes: 1, FailureThreshold: 3, CooldownMinutes: 5})
  90. require.NoError(t, err)
  91. settings := NewSettingService(&openAIAPIKeyHealthSettingRepo{value: string(encoded)}, &config.Config{})
  92. cache := &openAIAPIKeyHealthCacheStub{tripped: true}
  93. repo := &openAIAPIKeyHealthAccountRepo{}
  94. blocker := &openAIAPIKeyHealthRuntimeBlocker{}
  95. svc := NewRateLimitService(repo, nil, &config.Config{}, nil, cache)
  96. svc.SetSettingService(settings)
  97. svc.SetOpenAIAPIKeyHealthCache(cache)
  98. svc.SetAccountRuntimeBlocker(blocker)
  99. account := openAIHealthPoolAccount()
  100. require.True(t, svc.ObserveOpenAIAPIKeyHealthFailure(context.Background(), account, &UpstreamFailoverError{StatusCode: http.StatusBadGateway, ResponseBody: []byte(`{"error":"upstream"}`)}))
  101. require.Equal(t, 1, cache.recordCalls)
  102. require.Equal(t, 1, cache.setCalls)
  103. require.Equal(t, 1, repo.setCalls)
  104. require.Equal(t, 1, blocker.calls)
  105. require.NotNil(t, account.TempUnschedulableUntil)
  106. require.Contains(t, repo.reason, openAIAPIKeyHealthBreakerReason)
  107. }
  108. func TestOpenAIAPIKeyHealthSuccessDoesNotTouchSettingsOrCache(t *testing.T) {
  109. encoded, err := json.Marshal(OpenAIAPIKeyHealthBreakerSettings{Enabled: true, WindowMinutes: 1, FailureThreshold: 3, CooldownMinutes: 5})
  110. require.NoError(t, err)
  111. settingRepo := &openAIAPIKeyHealthSettingRepo{value: string(encoded)}
  112. settings := NewSettingService(settingRepo, &config.Config{})
  113. cache := &openAIAPIKeyHealthCacheStub{}
  114. svc := NewRateLimitService(&openAIAPIKeyHealthAccountRepo{}, nil, &config.Config{}, nil, cache)
  115. svc.SetSettingService(settings)
  116. svc.SetOpenAIAPIKeyHealthCache(cache)
  117. svc.ObserveOpenAIAPIKeyHealthSuccess(context.Background(), openAIHealthPoolAccount())
  118. svc.ObserveOpenAIAPIKeyHealthSuccess(context.Background(), &Account{ID: 43, Platform: PlatformOpenAI, Type: AccountTypeAPIKey})
  119. require.Zero(t, settingRepo.getCalls)
  120. require.Zero(t, cache.recordCalls)
  121. }