Skip to content

Commit 6a27bce

Browse files
authored
Merge pull request router-for-me#2576 from zilianpn/fix/disable-cooling-auth-errors
fix(auth): honor disable-cooling and enrich no-auth errors
2 parents 9f5bdfa + 0ea7680 commit 6a27bce

6 files changed

Lines changed: 436 additions & 35 deletions

File tree

config.example.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ max-retry-credentials: 0
8787
# Maximum wait time in seconds for a cooled-down credential before triggering a retry.
8888
max-retry-interval: 30
8989

90+
# When true, disable auth/model cooldown scheduling globally (prevents blackout windows after failure states).
91+
disable-cooling: false
92+
9093
# Quota exceeded behavior
9194
quota-exceeded:
9295
switch-project: true # Whether to automatically switch to another project when a quota is exceeded

sdk/api/handlers/handlers.go

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package handlers
66
import (
77
"bytes"
88
"encoding/json"
9+
"errors"
910
"fmt"
1011
"net/http"
1112
"strings"
@@ -492,6 +493,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
492493
opts.Metadata = reqMeta
493494
resp, err := h.AuthManager.Execute(ctx, providers, req, opts)
494495
if err != nil {
496+
err = enrichAuthSelectionError(err, providers, normalizedModel)
495497
status := http.StatusInternalServerError
496498
if se, ok := err.(interface{ StatusCode() int }); ok && se != nil {
497499
if code := se.StatusCode(); code > 0 {
@@ -538,6 +540,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
538540
opts.Metadata = reqMeta
539541
resp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts)
540542
if err != nil {
543+
err = enrichAuthSelectionError(err, providers, normalizedModel)
541544
status := http.StatusInternalServerError
542545
if se, ok := err.(interface{ StatusCode() int }); ok && se != nil {
543546
if code := se.StatusCode(); code > 0 {
@@ -588,6 +591,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
588591
opts.Metadata = reqMeta
589592
streamResult, err := h.AuthManager.ExecuteStream(ctx, providers, req, opts)
590593
if err != nil {
594+
err = enrichAuthSelectionError(err, providers, normalizedModel)
591595
errChan := make(chan *interfaces.ErrorMessage, 1)
592596
status := http.StatusInternalServerError
593597
if se, ok := err.(interface{ StatusCode() int }); ok && se != nil {
@@ -697,7 +701,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
697701
chunks = retryResult.Chunks
698702
continue outer
699703
}
700-
streamErr = retryErr
704+
streamErr = enrichAuthSelectionError(retryErr, providers, normalizedModel)
701705
}
702706
}
703707

@@ -840,6 +844,54 @@ func replaceHeader(dst http.Header, src http.Header) {
840844
}
841845
}
842846

847+
func enrichAuthSelectionError(err error, providers []string, model string) error {
848+
if err == nil {
849+
return nil
850+
}
851+
852+
var authErr *coreauth.Error
853+
if !errors.As(err, &authErr) || authErr == nil {
854+
return err
855+
}
856+
857+
code := strings.TrimSpace(authErr.Code)
858+
if code != "auth_not_found" && code != "auth_unavailable" {
859+
return err
860+
}
861+
862+
providerText := strings.Join(providers, ",")
863+
if providerText == "" {
864+
providerText = "unknown"
865+
}
866+
modelText := strings.TrimSpace(model)
867+
if modelText == "" {
868+
modelText = "unknown"
869+
}
870+
871+
baseMessage := strings.TrimSpace(authErr.Message)
872+
if baseMessage == "" {
873+
baseMessage = "no auth available"
874+
}
875+
detail := fmt.Sprintf("%s (providers=%s, model=%s)", baseMessage, providerText, modelText)
876+
877+
// Clarify the most common alias confusion between Anthropic route names and internal provider keys.
878+
if strings.Contains(","+providerText+",", ",claude,") {
879+
detail += "; check Claude auth/key session and cooldown state via /v0/management/auth-files"
880+
}
881+
882+
status := authErr.HTTPStatus
883+
if status <= 0 {
884+
status = http.StatusServiceUnavailable
885+
}
886+
887+
return &coreauth.Error{
888+
Code: authErr.Code,
889+
Message: detail,
890+
Retryable: authErr.Retryable,
891+
HTTPStatus: status,
892+
}
893+
}
894+
843895
// WriteErrorResponse writes an error message to the response writer using the HTTP status embedded in the message.
844896
func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.ErrorMessage) {
845897
status := http.StatusInternalServerError

sdk/api/handlers/handlers_error_response_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import (
55
"net/http"
66
"net/http/httptest"
77
"reflect"
8+
"strings"
89
"testing"
910

1011
"github.com/gin-gonic/gin"
1112
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
13+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
1214
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
1315
)
1416

@@ -66,3 +68,46 @@ func TestWriteErrorResponse_AddonHeadersEnabled(t *testing.T) {
6668
t.Fatalf("X-Request-Id = %#v, want %#v", got, []string{"new-1", "new-2"})
6769
}
6870
}
71+
72+
func TestEnrichAuthSelectionError_DefaultsTo503WithContext(t *testing.T) {
73+
in := &coreauth.Error{Code: "auth_not_found", Message: "no auth available"}
74+
out := enrichAuthSelectionError(in, []string{"claude"}, "claude-sonnet-4-6")
75+
76+
var got *coreauth.Error
77+
if !errors.As(out, &got) || got == nil {
78+
t.Fatalf("expected coreauth.Error, got %T", out)
79+
}
80+
if got.StatusCode() != http.StatusServiceUnavailable {
81+
t.Fatalf("status = %d, want %d", got.StatusCode(), http.StatusServiceUnavailable)
82+
}
83+
if !strings.Contains(got.Message, "providers=claude") {
84+
t.Fatalf("message missing provider context: %q", got.Message)
85+
}
86+
if !strings.Contains(got.Message, "model=claude-sonnet-4-6") {
87+
t.Fatalf("message missing model context: %q", got.Message)
88+
}
89+
if !strings.Contains(got.Message, "/v0/management/auth-files") {
90+
t.Fatalf("message missing management hint: %q", got.Message)
91+
}
92+
}
93+
94+
func TestEnrichAuthSelectionError_PreservesExplicitStatus(t *testing.T) {
95+
in := &coreauth.Error{Code: "auth_unavailable", Message: "no auth available", HTTPStatus: http.StatusTooManyRequests}
96+
out := enrichAuthSelectionError(in, []string{"gemini"}, "gemini-2.5-pro")
97+
98+
var got *coreauth.Error
99+
if !errors.As(out, &got) || got == nil {
100+
t.Fatalf("expected coreauth.Error, got %T", out)
101+
}
102+
if got.StatusCode() != http.StatusTooManyRequests {
103+
t.Fatalf("status = %d, want %d", got.StatusCode(), http.StatusTooManyRequests)
104+
}
105+
}
106+
107+
func TestEnrichAuthSelectionError_IgnoresOtherErrors(t *testing.T) {
108+
in := errors.New("boom")
109+
out := enrichAuthSelectionError(in, []string{"claude"}, "claude-sonnet-4-6")
110+
if out != in {
111+
t.Fatalf("expected original error to be returned unchanged")
112+
}
113+
}

sdk/api/handlers/handlers_stream_bootstrap_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ package handlers
22

33
import (
44
"context"
5+
"errors"
56
"net/http"
7+
"strings"
68
"sync"
79
"testing"
810

11+
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
912
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
1013
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
1114
coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
@@ -463,6 +466,76 @@ func TestExecuteStreamWithAuthManager_DoesNotRetryAfterFirstByte(t *testing.T) {
463466
}
464467
}
465468

469+
func TestExecuteStreamWithAuthManager_EnrichesBootstrapRetryAuthUnavailableError(t *testing.T) {
470+
executor := &failOnceStreamExecutor{}
471+
manager := coreauth.NewManager(nil, nil, nil)
472+
manager.RegisterExecutor(executor)
473+
474+
auth1 := &coreauth.Auth{
475+
ID: "auth1",
476+
Provider: "codex",
477+
Status: coreauth.StatusActive,
478+
Metadata: map[string]any{"email": "test1@example.com"},
479+
}
480+
if _, err := manager.Register(context.Background(), auth1); err != nil {
481+
t.Fatalf("manager.Register(auth1): %v", err)
482+
}
483+
484+
registry.GetGlobalRegistry().RegisterClient(auth1.ID, auth1.Provider, []*registry.ModelInfo{{ID: "test-model"}})
485+
t.Cleanup(func() {
486+
registry.GetGlobalRegistry().UnregisterClient(auth1.ID)
487+
})
488+
489+
handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{
490+
Streaming: sdkconfig.StreamingConfig{
491+
BootstrapRetries: 1,
492+
},
493+
}, manager)
494+
dataChan, _, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), "openai", "test-model", []byte(`{"model":"test-model"}`), "")
495+
if dataChan == nil || errChan == nil {
496+
t.Fatalf("expected non-nil channels")
497+
}
498+
499+
var got []byte
500+
for chunk := range dataChan {
501+
got = append(got, chunk...)
502+
}
503+
if len(got) != 0 {
504+
t.Fatalf("expected empty payload, got %q", string(got))
505+
}
506+
507+
var gotErr *interfaces.ErrorMessage
508+
for msg := range errChan {
509+
if msg != nil {
510+
gotErr = msg
511+
}
512+
}
513+
if gotErr == nil {
514+
t.Fatalf("expected terminal error")
515+
}
516+
if gotErr.StatusCode != http.StatusServiceUnavailable {
517+
t.Fatalf("status = %d, want %d", gotErr.StatusCode, http.StatusServiceUnavailable)
518+
}
519+
520+
var authErr *coreauth.Error
521+
if !errors.As(gotErr.Error, &authErr) || authErr == nil {
522+
t.Fatalf("expected coreauth.Error, got %T", gotErr.Error)
523+
}
524+
if authErr.Code != "auth_unavailable" {
525+
t.Fatalf("code = %q, want %q", authErr.Code, "auth_unavailable")
526+
}
527+
if !strings.Contains(authErr.Message, "providers=codex") {
528+
t.Fatalf("message missing provider context: %q", authErr.Message)
529+
}
530+
if !strings.Contains(authErr.Message, "model=test-model") {
531+
t.Fatalf("message missing model context: %q", authErr.Message)
532+
}
533+
534+
if executor.Calls() != 1 {
535+
t.Fatalf("expected exactly one upstream call before retry path selection failure, got %d", executor.Calls())
536+
}
537+
}
538+
466539
func TestExecuteStreamWithAuthManager_PinnedAuthKeepsSameUpstream(t *testing.T) {
467540
executor := &authAwareStreamExecutor{}
468541
manager := coreauth.NewManager(nil, nil, nil)

0 commit comments

Comments
 (0)