Files
CLIProxyAPI/sdk/cliproxy/auth/conductor_refresh.go
Luis Pater 4c1bebe837 fix(auth): preserve concurrent modifications during auth refresh and preparation
- Implement three-way merge for refreshed and prepared auth updates against base and current runtime state.
- Retain user modifications to metadata, attributes, proxy URL, and error/cooldown status across background refresh operations.
- Guard against stale registration epochs and enforce per-auth generation ordering during persistence.

Closes: #5465
2026-09-04 07:03:40 +08:00

575 lines
15 KiB
Go

package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
log "github.com/sirupsen/logrus"
)
// RefreshEvaluator allows runtime state to override refresh decisions.
type RefreshEvaluator interface {
ShouldRefresh(now time.Time, auth *Auth) bool
}
const (
refreshCheckInterval = 5 * time.Second
refreshMaxConcurrency = 16
refreshPendingBackoff = time.Minute
refreshFailureBackoff = 5 * time.Minute
// refreshIneffectiveBackoff throttles refresh attempts when an executor returns
// success but the auth still evaluates as needing refresh (e.g. token expiry
// wasn't updated). Without this guard, the auto-refresh loop can tight-loop and
// burn CPU at idle.
refreshIneffectiveBackoff = 30 * time.Second
quotaBackoffBase = time.Second
quotaBackoffMax = 30 * time.Minute
minQuotaCooldownFloor = 10 * time.Second
transientErrorCooldown = time.Minute
)
// StartAutoRefresh launches a background loop that evaluates auth freshness
// every few seconds and triggers refresh operations when required.
// Only one loop is kept alive; starting a new one cancels the previous run.
func (m *Manager) StartAutoRefresh(parent context.Context, interval time.Duration) {
if interval <= 0 {
interval = refreshCheckInterval
}
m.mu.Lock()
cancelPrev := m.refreshCancel
m.refreshCancel = nil
m.refreshLoop = nil
m.mu.Unlock()
if cancelPrev != nil {
cancelPrev()
}
ctx, cancelCtx := context.WithCancel(parent)
workers := refreshMaxConcurrency
if cfg, ok := m.runtimeConfig.Load().(*internalconfig.Config); ok && cfg != nil && cfg.AuthAutoRefreshWorkers > 0 {
workers = cfg.AuthAutoRefreshWorkers
}
loop := newAuthAutoRefreshLoop(m, interval, workers)
m.mu.Lock()
m.refreshCancel = cancelCtx
m.refreshLoop = loop
m.mu.Unlock()
loop.rebuild(time.Now())
go loop.run(ctx)
}
// StopAutoRefresh cancels the background refresh loop, if running.
// It also stops the selector if it implements StoppableSelector.
func (m *Manager) StopAutoRefresh() {
m.mu.Lock()
cancel := m.refreshCancel
m.refreshCancel = nil
m.refreshLoop = nil
m.mu.Unlock()
if cancel != nil {
cancel()
}
// Stop selector if it implements StoppableSelector (e.g., SessionAffinitySelector)
sel := m.Selector()
if stoppable, ok := sel.(StoppableSelector); ok && stoppable != nil {
stoppable.Stop()
}
}
func (m *Manager) queueRefreshReschedule(authID string) {
if m == nil || authID == "" {
return
}
m.mu.RLock()
loop := m.refreshLoop
m.mu.RUnlock()
if loop == nil {
return
}
loop.queueReschedule(authID)
}
func (m *Manager) queueRefreshUnschedule(authID string) {
if m == nil || authID == "" {
return
}
m.mu.RLock()
loop := m.refreshLoop
m.mu.RUnlock()
if loop == nil {
return
}
loop.remove(authID)
}
func (m *Manager) shouldRefresh(a *Auth, now time.Time) bool {
if a == nil {
return false
}
if hasUnauthorizedAuthFailure(a) {
return false
}
if !a.NextRefreshAfter.IsZero() && now.Before(a.NextRefreshAfter) {
return false
}
if evaluator, ok := a.Runtime.(RefreshEvaluator); ok && evaluator != nil {
return evaluator.ShouldRefresh(now, a)
}
lastRefresh := a.LastRefreshedAt
if lastRefresh.IsZero() {
if ts, ok := authLastRefreshTimestamp(a); ok {
lastRefresh = ts
}
}
expiry, hasExpiry := a.ExpirationTime()
if interval := authPreferredInterval(a); interval > 0 {
if hasExpiry && !expiry.IsZero() {
if !expiry.After(now) {
return true
}
if expiry.Sub(now) <= interval {
return true
}
}
if lastRefresh.IsZero() {
return true
}
return now.Sub(lastRefresh) >= interval
}
provider := strings.ToLower(a.Provider)
lead := ProviderRefreshLead(provider, a.Runtime)
if lead == nil {
return false
}
if *lead <= 0 {
if hasExpiry && !expiry.IsZero() {
return now.After(expiry)
}
return false
}
if hasExpiry && !expiry.IsZero() {
return time.Until(expiry) <= *lead
}
if !lastRefresh.IsZero() {
return now.Sub(lastRefresh) >= *lead
}
return true
}
func authPreferredInterval(a *Auth) time.Duration {
if a == nil {
return 0
}
if d := durationFromMetadata(a.Metadata, "refresh_interval_seconds", "refreshIntervalSeconds", "refresh_interval", "refreshInterval"); d > 0 {
return d
}
if d := durationFromAttributes(a.Attributes, "refresh_interval_seconds", "refreshIntervalSeconds", "refresh_interval", "refreshInterval"); d > 0 {
return d
}
return 0
}
func durationFromMetadata(meta map[string]any, keys ...string) time.Duration {
if len(meta) == 0 {
return 0
}
for _, key := range keys {
if val, ok := meta[key]; ok {
if dur := parseDurationValue(val); dur > 0 {
return dur
}
}
}
return 0
}
func durationFromAttributes(attrs map[string]string, keys ...string) time.Duration {
if len(attrs) == 0 {
return 0
}
for _, key := range keys {
if val, ok := attrs[key]; ok {
if dur := parseDurationString(val); dur > 0 {
return dur
}
}
}
return 0
}
func parseDurationValue(val any) time.Duration {
switch v := val.(type) {
case time.Duration:
if v <= 0 {
return 0
}
return v
case int:
if v <= 0 {
return 0
}
return time.Duration(v) * time.Second
case int32:
if v <= 0 {
return 0
}
return time.Duration(v) * time.Second
case int64:
if v <= 0 {
return 0
}
return time.Duration(v) * time.Second
case uint:
if v == 0 {
return 0
}
return time.Duration(v) * time.Second
case uint32:
if v == 0 {
return 0
}
return time.Duration(v) * time.Second
case uint64:
if v == 0 {
return 0
}
return time.Duration(v) * time.Second
case float32:
if v <= 0 {
return 0
}
return time.Duration(float64(v) * float64(time.Second))
case float64:
if v <= 0 {
return 0
}
return time.Duration(v * float64(time.Second))
case json.Number:
if i, err := v.Int64(); err == nil {
if i <= 0 {
return 0
}
return time.Duration(i) * time.Second
}
if f, err := v.Float64(); err == nil && f > 0 {
return time.Duration(f * float64(time.Second))
}
case string:
return parseDurationString(v)
}
return 0
}
func parseDurationString(raw string) time.Duration {
s := strings.TrimSpace(raw)
if s == "" {
return 0
}
if dur, err := time.ParseDuration(s); err == nil && dur > 0 {
return dur
}
if secs, err := strconv.ParseFloat(s, 64); err == nil && secs > 0 {
return time.Duration(secs * float64(time.Second))
}
return 0
}
func authLastRefreshTimestamp(a *Auth) (time.Time, bool) {
if a == nil {
return time.Time{}, false
}
if a.Metadata != nil {
if ts, ok := lookupMetadataTime(a.Metadata, "last_refresh", "lastRefresh", "last_refreshed_at", "lastRefreshedAt"); ok {
return ts, true
}
}
if a.Attributes != nil {
for _, key := range []string{"last_refresh", "lastRefresh", "last_refreshed_at", "lastRefreshedAt"} {
if val := strings.TrimSpace(a.Attributes[key]); val != "" {
if ts, ok := parseTimeValue(val); ok {
return ts, true
}
}
}
}
return time.Time{}, false
}
func lookupMetadataTime(meta map[string]any, keys ...string) (time.Time, bool) {
for _, key := range keys {
if val, ok := meta[key]; ok {
if ts, ok1 := parseTimeValue(val); ok1 {
return ts, true
}
}
}
return time.Time{}, false
}
func (m *Manager) markRefreshPending(id string, now time.Time) bool {
m.mu.Lock()
auth, ok := m.auths[id]
if !ok || auth == nil {
m.mu.Unlock()
return false
}
if !auth.NextRefreshAfter.IsZero() && now.Before(auth.NextRefreshAfter) {
m.mu.Unlock()
return false
}
auth.NextRefreshAfter = now.Add(refreshPendingBackoff)
auth.Generation++
auth.UpdatedAt = now
m.auths[id] = auth
m.mu.Unlock()
m.queueRefreshReschedule(id)
return true
}
type authRefreshLock struct {
mu sync.Mutex
}
func authAccessToken(auth *Auth) string {
if token := authMetadataString(auth, "access_token"); token != "" {
return token
}
return authMetadataString(auth, "accessToken")
}
func authHasRefreshCredential(auth *Auth) bool {
if authMetadataString(auth, "refresh_token") != "" {
return true
}
return authMetadataString(auth, "refreshToken") != ""
}
func clearUnauthorizedModelStates(auth *Auth, now time.Time) []string {
if auth == nil || len(auth.ModelStates) == 0 {
return nil
}
var resumed []string
for model, state := range auth.ModelStates {
if state == nil || state.LastError == nil {
continue
}
if state.LastError.StatusCode() != http.StatusUnauthorized && !strings.EqualFold(state.LastError.Code, "unauthorized") {
continue
}
resetModelState(state, now)
resumed = append(resumed, model)
}
if len(resumed) > 0 {
updateAggregatedAvailability(auth, now)
}
return resumed
}
// RefreshHomeSelectionAfterUnauthorized only reuses a newer snapshot already
// installed by Home. It never refreshes or mutates Home-owned credentials.
func (m *Manager) RefreshHomeSelectionAfterUnauthorized(_ context.Context, selection *HomeDispatchSelection, failedAuth *Auth) (*Auth, bool, error) {
if m == nil || selection == nil {
return nil, false, nil
}
current := selection.CloneAuth()
if failedAuth == nil {
failedAuth = current
}
if current != nil && failedAuth != nil && current.ID == failedAuth.ID {
currentToken := authAccessToken(current)
failedToken := authAccessToken(failedAuth)
if currentToken != "" && failedToken != "" && currentToken != failedToken {
return current, true, nil
}
}
return current, false, nil
}
// tryRefreshAfterUnauthorized refreshes local OAuth credentials once after a
// 401 so the current auth can be retried before fallback/suspend.
func (m *Manager) tryRefreshAfterUnauthorized(ctx context.Context, auth *Auth, execErr error, alreadyTried bool) (*Auth, bool) {
if m == nil || auth == nil || alreadyTried || execErr == nil {
return auth, false
}
// Request-scoped failures describe this request, not stale credentials.
// Refreshing would turn a direct error response into an implicit retry.
if isRequestScopedError(execErr) {
return auth, false
}
if !isUnauthorizedError(execErr) || !authHasRefreshCredential(auth) {
return auth, false
}
log.Debugf("unauthorized response for %s (%s), refreshing credentials before fallback", auth.Provider, auth.ID)
refreshed, errRefresh := m.refreshAuthForRequest(ctx, auth.ID, authAccessToken(auth))
if errRefresh != nil || refreshed == nil {
log.Debugf("credential refresh before fallback failed for %s (%s): %v", auth.Provider, auth.ID, errRefresh)
return auth, false
}
return refreshed, true
}
func (m *Manager) refreshAuth(ctx context.Context, id string) {
_, _ = m.refreshAuthForRequest(ctx, id, "")
}
// refreshAuthForRequest performs a synchronous credential refresh for the given auth.
// failedAccessToken lets concurrent callers reuse a refresh that already replaced the
// access token that produced the unauthorized response.
func (m *Manager) refreshAuthForRequest(ctx context.Context, id, failedAccessToken string) (*Auth, error) {
if m == nil {
return nil, errors.New("auth manager is nil")
}
if ctx == nil {
ctx = context.Background()
}
id = strings.TrimSpace(id)
if id == "" {
return nil, errors.New("auth id is empty")
}
lockValue, _ := m.refreshLocks.LoadOrStore(id, &authRefreshLock{})
lock, _ := lockValue.(*authRefreshLock)
if lock == nil {
lock = &authRefreshLock{}
m.refreshLocks.Store(id, lock)
}
lock.mu.Lock()
defer lock.mu.Unlock()
m.mu.RLock()
auth := m.auths[id]
var exec ProviderExecutor
if auth != nil {
// Use the same effective provider key as request execution so OpenAI-compat
// auths registered under namespaced keys still resolve for refresh.
exec = m.executors[executorKeyFromAuth(auth)]
}
m.mu.RUnlock()
if auth == nil || exec == nil {
return nil, errors.New("auth or executor not found")
}
// Another request may already have refreshed this credential.
if failedAccessToken != "" {
if currentToken := authAccessToken(auth); currentToken != "" && currentToken != failedAccessToken {
return auth.Clone(), nil
}
}
base := auth.Clone()
updated, err := exec.Refresh(ctx, base.Clone())
if err != nil && errors.Is(err, context.Canceled) {
log.Debugf("refresh canceled for %s, %s", auth.Provider, auth.ID)
return nil, err
}
log.Debugf("refreshed %s, %s, %v", auth.Provider, auth.ID, err)
now := time.Now()
if err != nil {
unauthorized := isUnauthorizedError(err)
shouldReschedule := false
m.mu.Lock()
if current := m.auths[id]; current != nil {
if base != nil && current.RegistrationEpoch != base.RegistrationEpoch {
m.mu.Unlock()
return nil, err
}
current.Generation++
current.UpdatedAt = now
current.LastError = refreshErrorFromError(err)
hasValidAccessToken := current.HasValidAccessToken(now)
if !hasValidAccessToken {
current.Unavailable = true
current.Status = StatusError
if unauthorized {
current.NextRefreshAfter = time.Time{}
current.StatusMessage = "unauthorized"
} else {
current.NextRefreshAfter = now.Add(refreshFailureBackoff)
current.StatusMessage = "token expired"
}
} else {
// Access token remains valid. Preserve current in-flight/cooldown status without overwrite.
nextRetry := now.Add(refreshFailureBackoff)
if exp, ok := current.AccessTokenExpirationTime(); ok && !exp.IsZero() && nextRetry.After(exp) {
nextRetry = exp
}
current.NextRefreshAfter = nextRetry
if !current.Unavailable {
log.Warnf("credential refresh failed for %s (%s): %s; retaining active credential as access token is unexpired", current.Provider, current.ID, safeErrorDiagnosticForLog(err))
}
}
m.auths[id] = current
shouldReschedule = true
if m.scheduler != nil {
m.scheduler.upsertAuth(current.Clone())
}
}
m.mu.Unlock()
if shouldReschedule {
m.queueRefreshReschedule(id)
}
return nil, err
}
if updated == nil {
updated = base.Clone()
}
// Preserve runtime created by the executor during Refresh.
// If executor didn't set one, fall back to the previous runtime.
if updated.Runtime == nil {
updated.Runtime = auth.Runtime
}
updated.LastRefreshedAt = now
updated.NextRefreshAfter = time.Time{}
updated.LastError = nil
updated.StatusMessage = ""
updated.Unavailable = false
if updated.Status == StatusError || updated.Status == "" {
updated.Status = StatusActive
}
updated.UpdatedAt = now
_ = clearUnauthorizedModelStates(updated, now)
if m.shouldRefresh(updated, now) {
updated.NextRefreshAfter = now.Add(refreshIneffectiveBackoff)
}
saved, errUpdate := m.UpdateRefreshedAuth(ctx, base, updated)
if errUpdate != nil {
log.Debugf("persist refreshed auth %s (%s) failed: %v", auth.Provider, auth.ID, errUpdate)
return nil, errUpdate
}
if saved == nil {
return nil, fmt.Errorf("auth %s not found", id)
}
targetAuth := saved
supportedModels, regEpoch := registry.GetGlobalRegistry().GetModelsAndEpochForClient(id)
projections := make([]registry.ClientModelProjection, 0, len(supportedModels))
for _, sm := range supportedModels {
if sm == nil || strings.TrimSpace(sm.ID) == "" {
continue
}
projections = append(projections, m.clientModelProjectionForAuth(targetAuth, sm.ID, now))
}
if targetAuth != nil && len(projections) > 0 {
registry.GetGlobalRegistry().ApplyClientModelProjections(id, regEpoch, targetAuth.Generation, projections)
}
return saved.Clone(), nil
}