History

feat(auth): improve unauthorized error handling for refresh and auto-refresh

- Added `isUnauthorizedError` and `hasUnauthorizedAuthFailure` to classify and handle unauthorized errors. - Introduced `refreshErrorFromError` to map errors to standardized unauthorized responses. - Modified refresh logic to stop auto-refresh retries for unauthorized errors. - Updated tests to verify unauthorized error handling and refresh retry prevention.

Luis Pater committed 6 months agov7.0.4

Commit 6bfcb0c1 parent bd8c05a5 files · +131 −3Browse files

Unified · Split

5 files
  1. internal/runtime/executor/helps/home_refresh.go +12 −1
  2. internal/runtime/executor/helps/home_refresh_test.go +15 −0
  3. sdk/cliproxy/auth/auto_refresh_loop.go +3 −0
  4. sdk/cliproxy/auth/conductor.go +47 −2
  5. sdk/cliproxy/auth/conductor_scheduler_refresh_test.go +54 −0
internal/runtime/executor/helps/home_refresh.go+12−1
@@ -78,7 +78,7 @@ func RefreshAuthViaHome(ctx context.Context, cfg *config.Config, auth *cliproxya · 77 unmodified lines
78 if msg == "" {78 if msg == "" {
79 msg = "home returned error"79 msg = "home returned error"
80 }80 }
81 return nil, true, homeStatusErr{code: http.StatusBadGateway, msg: msg}81 return nil, true, homeStatusErr{code: statusFromHomeErrorCode(code), msg: msg}
82 }82 }
83 83
84 var updated cliproxyauth.Auth84 var updated cliproxyauth.Auth
@@ -89,3 +89,14 @@ func RefreshAuthViaHome(ctx context.Context, cfg *config.Config, auth *cliproxya · 4 unmodified lines
89 updated.EnsureIndex()89 updated.EnsureIndex()
90 return &updated, true, nil90 return &updated, true, nil
91}91}
92
93func statusFromHomeErrorCode(code string) int {
94 switch strings.ToLower(strings.TrimSpace(code)) {
95 case "authentication_error", "unauthorized":
96 return http.StatusUnauthorized
97 case "model_not_found":
98 return http.StatusNotFound
99 default:
100 return http.StatusBadGateway
101 }
102}
internal/runtime/executor/helps/home_refresh_test.go+15−0
@@ -0,0 +1,15 @@
1package helps
2
3import (
4 "net/http"
5 "testing"
6)
7
8func TestStatusFromHomeErrorCodeMapsAuthenticationErrorToUnauthorized(t *testing.T) {
9 if got := statusFromHomeErrorCode("authentication_error"); got != http.StatusUnauthorized {
10 t.Fatalf("statusFromHomeErrorCode(authentication_error) = %d, want %d", got, http.StatusUnauthorized)
11 }
12 if got := statusFromHomeErrorCode("unauthorized"); got != http.StatusUnauthorized {
13 t.Fatalf("statusFromHomeErrorCode(unauthorized) = %d, want %d", got, http.StatusUnauthorized)
14 }
15}
sdk/cliproxy/auth/auto_refresh_loop.go+3−0
@@ -339,6 +339,9 @@ func nextRefreshCheckAt(now time.Time, auth *Auth, interval time.Duration) (time · 338 unmodified lines
339 if auth == nil {339 if auth == nil {
340 return time.Time{}, false340 return time.Time{}, false
341 }341 }
342 if hasUnauthorizedAuthFailure(auth) {
343 return time.Time{}, false
344 }
342 345
343 accountType, _ := auth.AccountInfo()346 accountType, _ := auth.AccountInfo()
344 if accountType == "api_key" {347 if accountType == "api_key" {
sdk/cliproxy/auth/conductor.go+47−2
@@ -2486,6 +2486,40 @@ func statusCodeFromError(err error) int { · 2485 unmodified lines
2486 return 02486 return 0
2487}2487}
2488 2488
2489func isUnauthorizedError(err error) bool {
2490 if err == nil {
2491 return false
2492 }
2493 if statusCodeFromError(err) == http.StatusUnauthorized {
2494 return true
2495 }
2496 raw := strings.ToLower(err.Error())
2497 return strings.Contains(raw, "status 401") || strings.Contains(raw, "401 unauthorized")
2498}
2499
2500func hasUnauthorizedAuthFailure(auth *Auth) bool {
2501 if auth == nil || auth.LastError == nil {
2502 return false
2503 }
2504 return auth.LastError.StatusCode() == http.StatusUnauthorized || strings.EqualFold(auth.LastError.Code, "unauthorized")
2505}
2506
2507func refreshErrorFromError(err error) *Error {
2508 if err == nil {
2509 return nil
2510 }
2511 statusCode := statusCodeFromError(err)
2512 if statusCode == 0 && isUnauthorizedError(err) {
2513 statusCode = http.StatusUnauthorized
2514 }
2515 authErr := &Error{Message: err.Error(), HTTPStatus: statusCode}
2516 if statusCode == http.StatusUnauthorized {
2517 authErr.Code = "unauthorized"
2518 authErr.Retryable = false
2519 }
2520 return authErr
2521}
2522
2489func retryAfterFromError(err error) *time.Duration {2523func retryAfterFromError(err error) *time.Duration {
2490 if err == nil {2524 if err == nil {
2491 return nil2525 return nil
@@ -3680,6 +3714,9 @@ func (m *Manager) shouldRefresh(a *Auth, now time.Time) bool { · 1188 unmodified lines
3680 if a == nil {3714 if a == nil {
3681 return false3715 return false
3682 }3716 }
3717 if hasUnauthorizedAuthFailure(a) {
3718 return false
3719 }
3683 if !a.NextRefreshAfter.IsZero() && now.Before(a.NextRefreshAfter) {3720 if !a.NextRefreshAfter.IsZero() && now.Before(a.NextRefreshAfter) {
3684 return false3721 return false
3685 }3722 }
@@ -3924,11 +3961,19 @@ func (m *Manager) refreshAuth(ctx context.Context, id string) { · 238 unmodified lines
3924 log.Debugf("refreshed %s, %s, %v", auth.Provider, auth.ID, err)3961 log.Debugf("refreshed %s, %s, %v", auth.Provider, auth.ID, err)
3925 now := time.Now()3962 now := time.Now()
3926 if err != nil {3963 if err != nil {
3964 unauthorized := isUnauthorizedError(err)
3927 shouldReschedule := false3965 shouldReschedule := false
3928 m.mu.Lock()3966 m.mu.Lock()
3929 if current := m.auths[id]; current != nil {3967 if current := m.auths[id]; current != nil {
3930 current.NextRefreshAfter = now.Add(refreshFailureBackoff)3968 current.LastError = refreshErrorFromError(err)
3931 current.LastError = &Error{Message: err.Error()}3969 if unauthorized {
3970 current.NextRefreshAfter = time.Time{}
3971 current.Unavailable = true
3972 current.Status = StatusError
3973 current.StatusMessage = "unauthorized"
3974 } else {
3975 current.NextRefreshAfter = now.Add(refreshFailureBackoff)
3976 }
3932 m.auths[id] = current3977 m.auths[id] = current
3933 shouldReschedule = true3978 shouldReschedule = true
3934 if m.scheduler != nil {3979 if m.scheduler != nil {
sdk/cliproxy/auth/conductor_scheduler_refresh_test.go+54−0
@@ -5,6 +5,7 @@ import ( · 4 unmodified lines
5 "errors"5 "errors"
6 "net/http"6 "net/http"
7 "testing"7 "testing"
8 "time"
8 9
9 "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"10 "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
10 cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"11 cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
@@ -36,6 +37,59 @@ func (e schedulerProviderTestExecutor) HttpRequest(ctx context.Context, auth *Au · 25 unmodified lines
36 return nil, nil37 return nil, nil
37}38}
38 39
40type unauthorizedRefreshTestExecutor struct {
41 schedulerProviderTestExecutor
42}
43
44func (e unauthorizedRefreshTestExecutor) Refresh(ctx context.Context, auth *Auth) (*Auth, error) {
45 return nil, errors.New("token refresh failed with status 401: invalid_grant")
46}
47
48func TestManager_RefreshAuthUnauthorizedFailureStopsAutoRefreshRetry(t *testing.T) {
49 ctx := context.Background()
50 manager := NewManager(nil, &RoundRobinSelector{}, nil)
51 manager.RegisterExecutor(unauthorizedRefreshTestExecutor{
52 schedulerProviderTestExecutor: schedulerProviderTestExecutor{provider: "codex"},
53 })
54
55 auth := &Auth{
56 ID: "unauthorized-refresh",
57 Provider: "codex",
58 Metadata: map[string]any{
59 "email": "x@example.com",
60 },
61 }
62 if _, errRegister := manager.Register(ctx, auth); errRegister != nil {
63 t.Fatalf("register auth: %v", errRegister)
64 }
65
66 manager.refreshAuth(ctx, auth.ID)
67
68 updated, ok := manager.GetByID(auth.ID)
69 if !ok {
70 t.Fatalf("expected auth %q after refresh", auth.ID)
71 }
72 if updated.LastError == nil {
73 t.Fatal("expected unauthorized refresh failure to be recorded")
74 }
75 if got := updated.LastError.StatusCode(); got != http.StatusUnauthorized {
76 t.Fatalf("LastError.StatusCode() = %d, want %d", got, http.StatusUnauthorized)
77 }
78 if updated.LastError.Code != "unauthorized" {
79 t.Fatalf("LastError.Code = %q, want unauthorized", updated.LastError.Code)
80 }
81 if !updated.NextRefreshAfter.IsZero() {
82 t.Fatalf("NextRefreshAfter = %s, want zero for unauthorized refresh failure", updated.NextRefreshAfter)
83 }
84 now := time.Now()
85 if manager.shouldRefresh(updated, now) {
86 t.Fatal("expected unauthorized auth to stop refresh attempts")
87 }
88 if _, shouldSchedule := nextRefreshCheckAt(now, updated, time.Second); shouldSchedule {
89 t.Fatal("expected unauthorized auth to be removed from the auto-refresh schedule")
90 }
91}
92
39func TestManager_RefreshSchedulerEntry_RebuildsSupportedModelSetAfterModelRegistration(t *testing.T) {93func TestManager_RefreshSchedulerEntry_RebuildsSupportedModelSetAfterModelRegistration(t *testing.T) {
40 ctx := context.Background()94 ctx := context.Background()
41 95

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t