Fixed: #1747
feat(auth): add configurable max-retry-credentials for finer control over cross-credential retries
This commit is contained in:
@@ -138,8 +138,9 @@ type Manager struct {
|
||||
providerOffsets map[string]int
|
||||
|
||||
// Retry controls request retry behavior.
|
||||
requestRetry atomic.Int32
|
||||
maxRetryInterval atomic.Int64
|
||||
requestRetry atomic.Int32
|
||||
maxRetryCredentials atomic.Int32
|
||||
maxRetryInterval atomic.Int64
|
||||
|
||||
// oauthModelAlias stores global OAuth model alias mappings (alias -> upstream name) keyed by channel.
|
||||
oauthModelAlias atomic.Value
|
||||
@@ -384,18 +385,22 @@ func compileAPIKeyModelAliasForModels[T interface {
|
||||
}
|
||||
}
|
||||
|
||||
// SetRetryConfig updates retry attempts and cooldown wait interval.
|
||||
func (m *Manager) SetRetryConfig(retry int, maxRetryInterval time.Duration) {
|
||||
// SetRetryConfig updates retry attempts, credential retry limit and cooldown wait interval.
|
||||
func (m *Manager) SetRetryConfig(retry int, maxRetryInterval time.Duration, maxRetryCredentials int) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
if retry < 0 {
|
||||
retry = 0
|
||||
}
|
||||
if maxRetryCredentials < 0 {
|
||||
maxRetryCredentials = 0
|
||||
}
|
||||
if maxRetryInterval < 0 {
|
||||
maxRetryInterval = 0
|
||||
}
|
||||
m.requestRetry.Store(int32(retry))
|
||||
m.maxRetryCredentials.Store(int32(maxRetryCredentials))
|
||||
m.maxRetryInterval.Store(maxRetryInterval.Nanoseconds())
|
||||
}
|
||||
|
||||
@@ -506,11 +511,11 @@ func (m *Manager) Execute(ctx context.Context, providers []string, req cliproxye
|
||||
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
||||
}
|
||||
|
||||
_, maxWait := m.retrySettings()
|
||||
_, maxRetryCredentials, maxWait := m.retrySettings()
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; ; attempt++ {
|
||||
resp, errExec := m.executeMixedOnce(ctx, normalized, req, opts)
|
||||
resp, errExec := m.executeMixedOnce(ctx, normalized, req, opts, maxRetryCredentials)
|
||||
if errExec == nil {
|
||||
return resp, nil
|
||||
}
|
||||
@@ -537,11 +542,11 @@ func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req clip
|
||||
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
||||
}
|
||||
|
||||
_, maxWait := m.retrySettings()
|
||||
_, maxRetryCredentials, maxWait := m.retrySettings()
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; ; attempt++ {
|
||||
resp, errExec := m.executeCountMixedOnce(ctx, normalized, req, opts)
|
||||
resp, errExec := m.executeCountMixedOnce(ctx, normalized, req, opts, maxRetryCredentials)
|
||||
if errExec == nil {
|
||||
return resp, nil
|
||||
}
|
||||
@@ -568,11 +573,11 @@ func (m *Manager) ExecuteStream(ctx context.Context, providers []string, req cli
|
||||
return nil, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
||||
}
|
||||
|
||||
_, maxWait := m.retrySettings()
|
||||
_, maxRetryCredentials, maxWait := m.retrySettings()
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; ; attempt++ {
|
||||
result, errStream := m.executeStreamMixedOnce(ctx, normalized, req, opts)
|
||||
result, errStream := m.executeStreamMixedOnce(ctx, normalized, req, opts, maxRetryCredentials)
|
||||
if errStream == nil {
|
||||
return result, nil
|
||||
}
|
||||
@@ -591,7 +596,7 @@ func (m *Manager) ExecuteStream(ctx context.Context, providers []string, req cli
|
||||
return nil, &Error{Code: "auth_not_found", Message: "no auth available"}
|
||||
}
|
||||
|
||||
func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||
func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, maxRetryCredentials int) (cliproxyexecutor.Response, error) {
|
||||
if len(providers) == 0 {
|
||||
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
||||
}
|
||||
@@ -600,6 +605,12 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req
|
||||
tried := make(map[string]struct{})
|
||||
var lastErr error
|
||||
for {
|
||||
if maxRetryCredentials > 0 && len(tried) >= maxRetryCredentials {
|
||||
if lastErr != nil {
|
||||
return cliproxyexecutor.Response{}, lastErr
|
||||
}
|
||||
return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"}
|
||||
}
|
||||
auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried)
|
||||
if errPick != nil {
|
||||
if lastErr != nil {
|
||||
@@ -647,7 +658,7 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||
func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, maxRetryCredentials int) (cliproxyexecutor.Response, error) {
|
||||
if len(providers) == 0 {
|
||||
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
||||
}
|
||||
@@ -656,6 +667,12 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string,
|
||||
tried := make(map[string]struct{})
|
||||
var lastErr error
|
||||
for {
|
||||
if maxRetryCredentials > 0 && len(tried) >= maxRetryCredentials {
|
||||
if lastErr != nil {
|
||||
return cliproxyexecutor.Response{}, lastErr
|
||||
}
|
||||
return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"}
|
||||
}
|
||||
auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried)
|
||||
if errPick != nil {
|
||||
if lastErr != nil {
|
||||
@@ -703,7 +720,7 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
|
||||
func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, maxRetryCredentials int) (*cliproxyexecutor.StreamResult, error) {
|
||||
if len(providers) == 0 {
|
||||
return nil, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
||||
}
|
||||
@@ -712,6 +729,12 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string
|
||||
tried := make(map[string]struct{})
|
||||
var lastErr error
|
||||
for {
|
||||
if maxRetryCredentials > 0 && len(tried) >= maxRetryCredentials {
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, &Error{Code: "auth_not_found", Message: "no auth available"}
|
||||
}
|
||||
auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried)
|
||||
if errPick != nil {
|
||||
if lastErr != nil {
|
||||
@@ -1108,11 +1131,11 @@ func (m *Manager) normalizeProviders(providers []string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *Manager) retrySettings() (int, time.Duration) {
|
||||
func (m *Manager) retrySettings() (int, int, time.Duration) {
|
||||
if m == nil {
|
||||
return 0, 0
|
||||
return 0, 0, 0
|
||||
}
|
||||
return int(m.requestRetry.Load()), time.Duration(m.maxRetryInterval.Load())
|
||||
return int(m.requestRetry.Load()), int(m.maxRetryCredentials.Load()), time.Duration(m.maxRetryInterval.Load())
|
||||
}
|
||||
|
||||
func (m *Manager) closestCooldownWait(providers []string, model string, attempt int) (time.Duration, bool) {
|
||||
|
||||
@@ -2,13 +2,17 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
)
|
||||
|
||||
func TestManager_ShouldRetryAfterError_RespectsAuthRequestRetryOverride(t *testing.T) {
|
||||
m := NewManager(nil, nil, nil)
|
||||
m.SetRetryConfig(3, 30*time.Second)
|
||||
m.SetRetryConfig(3, 30*time.Second, 0)
|
||||
|
||||
model := "test-model"
|
||||
next := time.Now().Add(5 * time.Second)
|
||||
@@ -31,7 +35,7 @@ func TestManager_ShouldRetryAfterError_RespectsAuthRequestRetryOverride(t *testi
|
||||
t.Fatalf("register auth: %v", errRegister)
|
||||
}
|
||||
|
||||
_, maxWait := m.retrySettings()
|
||||
_, _, maxWait := m.retrySettings()
|
||||
wait, shouldRetry := m.shouldRetryAfterError(&Error{HTTPStatus: 500, Message: "boom"}, 0, []string{"claude"}, model, maxWait)
|
||||
if shouldRetry {
|
||||
t.Fatalf("expected shouldRetry=false for request_retry=0, got true (wait=%v)", wait)
|
||||
@@ -56,6 +60,124 @@ func TestManager_ShouldRetryAfterError_RespectsAuthRequestRetryOverride(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
type credentialRetryLimitExecutor struct {
|
||||
id string
|
||||
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
}
|
||||
|
||||
func (e *credentialRetryLimitExecutor) Identifier() string {
|
||||
return e.id
|
||||
}
|
||||
|
||||
func (e *credentialRetryLimitExecutor) Execute(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||
e.recordCall()
|
||||
return cliproxyexecutor.Response{}, &Error{HTTPStatus: 500, Message: "boom"}
|
||||
}
|
||||
|
||||
func (e *credentialRetryLimitExecutor) ExecuteStream(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
|
||||
e.recordCall()
|
||||
return nil, &Error{HTTPStatus: 500, Message: "boom"}
|
||||
}
|
||||
|
||||
func (e *credentialRetryLimitExecutor) Refresh(_ context.Context, auth *Auth) (*Auth, error) {
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
func (e *credentialRetryLimitExecutor) CountTokens(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||
e.recordCall()
|
||||
return cliproxyexecutor.Response{}, &Error{HTTPStatus: 500, Message: "boom"}
|
||||
}
|
||||
|
||||
func (e *credentialRetryLimitExecutor) HttpRequest(context.Context, *Auth, *http.Request) (*http.Response, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (e *credentialRetryLimitExecutor) recordCall() {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.calls++
|
||||
}
|
||||
|
||||
func (e *credentialRetryLimitExecutor) Calls() int {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.calls
|
||||
}
|
||||
|
||||
func newCredentialRetryLimitTestManager(t *testing.T, maxRetryCredentials int) (*Manager, *credentialRetryLimitExecutor) {
|
||||
t.Helper()
|
||||
|
||||
m := NewManager(nil, nil, nil)
|
||||
m.SetRetryConfig(0, 0, maxRetryCredentials)
|
||||
|
||||
executor := &credentialRetryLimitExecutor{id: "claude"}
|
||||
m.RegisterExecutor(executor)
|
||||
|
||||
auth1 := &Auth{ID: "auth-1", Provider: "claude"}
|
||||
auth2 := &Auth{ID: "auth-2", Provider: "claude"}
|
||||
if _, errRegister := m.Register(context.Background(), auth1); errRegister != nil {
|
||||
t.Fatalf("register auth1: %v", errRegister)
|
||||
}
|
||||
if _, errRegister := m.Register(context.Background(), auth2); errRegister != nil {
|
||||
t.Fatalf("register auth2: %v", errRegister)
|
||||
}
|
||||
|
||||
return m, executor
|
||||
}
|
||||
|
||||
func TestManager_MaxRetryCredentials_LimitsCrossCredentialRetries(t *testing.T) {
|
||||
request := cliproxyexecutor.Request{Model: "test-model"}
|
||||
testCases := []struct {
|
||||
name string
|
||||
invoke func(*Manager) error
|
||||
}{
|
||||
{
|
||||
name: "execute",
|
||||
invoke: func(m *Manager) error {
|
||||
_, errExecute := m.Execute(context.Background(), []string{"claude"}, request, cliproxyexecutor.Options{})
|
||||
return errExecute
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "execute_count",
|
||||
invoke: func(m *Manager) error {
|
||||
_, errExecute := m.ExecuteCount(context.Background(), []string{"claude"}, request, cliproxyexecutor.Options{})
|
||||
return errExecute
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "execute_stream",
|
||||
invoke: func(m *Manager) error {
|
||||
_, errExecute := m.ExecuteStream(context.Background(), []string{"claude"}, request, cliproxyexecutor.Options{})
|
||||
return errExecute
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
limitedManager, limitedExecutor := newCredentialRetryLimitTestManager(t, 1)
|
||||
if errInvoke := tc.invoke(limitedManager); errInvoke == nil {
|
||||
t.Fatalf("expected error for limited retry execution")
|
||||
}
|
||||
if calls := limitedExecutor.Calls(); calls != 1 {
|
||||
t.Fatalf("expected 1 call with max-retry-credentials=1, got %d", calls)
|
||||
}
|
||||
|
||||
unlimitedManager, unlimitedExecutor := newCredentialRetryLimitTestManager(t, 0)
|
||||
if errInvoke := tc.invoke(unlimitedManager); errInvoke == nil {
|
||||
t.Fatalf("expected error for unlimited retry execution")
|
||||
}
|
||||
if calls := unlimitedExecutor.Calls(); calls != 2 {
|
||||
t.Fatalf("expected 2 calls with max-retry-credentials=0, got %d", calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_MarkResult_RespectsAuthDisableCoolingOverride(t *testing.T) {
|
||||
prev := quotaCooldownDisabled.Load()
|
||||
quotaCooldownDisabled.Store(false)
|
||||
|
||||
@@ -336,7 +336,7 @@ func (s *Service) applyRetryConfig(cfg *config.Config) {
|
||||
return
|
||||
}
|
||||
maxInterval := time.Duration(cfg.MaxRetryInterval) * time.Second
|
||||
s.coreManager.SetRetryConfig(cfg.RequestRetry, maxInterval)
|
||||
s.coreManager.SetRetryConfig(cfg.RequestRetry, maxInterval, cfg.MaxRetryCredentials)
|
||||
}
|
||||
|
||||
func openAICompatInfoFromAuth(a *coreauth.Auth) (providerKey string, compatName string, ok bool) {
|
||||
|
||||
Reference in New Issue
Block a user