From 45f58d4f91b78be9c27eac737a22934ff9c392fa Mon Sep 17 00:00:00 2001 From: sususu98 Date: Wed, 3 Jun 2026 10:25:10 +0800 Subject: [PATCH 1/2] fix(auth): retry and backoff cloudflare challenge 403 errors Introduce Cloudflare challenge detection for 403 errors in the Auth Manager. Apply a progressive rate-limiting cooldown ladder using the existing BackoffLevel field instead of a hard 30-minute credentials suspension. This ensures challenged requests fall through to subsequent credentials and recover exponentially. Co-Authored-By: Claude Opus 4.8 --- sdk/cliproxy/auth/conductor.go | 71 +++++++++++++++++++ sdk/cliproxy/auth/conductor_overrides_test.go | 54 ++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index c5c7e3f9..76c2a7ae 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -2351,6 +2351,30 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) { state.NextRetryAfter = next suspendReason = "model_not_supported" shouldSuspendModel = true + } else if isCloudflareChallengeResultError(result.Error) { + var next time.Time + backoffLevel := state.Quota.BackoffLevel + if !disableCooling { + cooldown, nextLevel := nextQuotaCooldown(backoffLevel, disableCooling) + if cooldown < 10*time.Second { + cooldown = 10 * time.Second + } + if cooldown > 0 { + next = now.Add(cooldown) + } + backoffLevel = nextLevel + } + state.NextRetryAfter = next + state.StatusMessage = "cloudflare challenge" + if auth.LastError != nil { + auth.StatusMessage = "cloudflare challenge" + } + state.Quota = QuotaState{ + Exceeded: true, + Reason: "cloudflare challenge", + NextRecoverAt: next, + BackoffLevel: backoffLevel, + } } else { switch statusCode { case 401: @@ -2750,6 +2774,27 @@ func isModelSupportResultError(err *Error) bool { return isModelSupportErrorMessage(err.Message) } +func isCloudflareChallengeErrorMessage(message string) bool { + lower := strings.ToLower(strings.TrimSpace(message)) + return strings.Contains(lower, "challenge-platform") || + strings.Contains(lower, "cf-mitigated") || + strings.Contains(lower, "challenge") || + (strings.Contains(lower, "cloudflare") && strings.Contains(lower, " 0 { + next = now.Add(cooldown) + } + backoffLevel = nextLevel + } + auth.Quota = QuotaState{ + Exceeded: true, + Reason: "cloudflare challenge", + NextRecoverAt: next, + BackoffLevel: backoffLevel, + } + auth.NextRetryAfter = next + return + } switch statusCode { case 401: auth.StatusMessage = "unauthorized" diff --git a/sdk/cliproxy/auth/conductor_overrides_test.go b/sdk/cliproxy/auth/conductor_overrides_test.go index 017602e3..5acd331e 100644 --- a/sdk/cliproxy/auth/conductor_overrides_test.go +++ b/sdk/cliproxy/auth/conductor_overrides_test.go @@ -570,6 +570,60 @@ func TestManager_MarkResult_RespectsAuthDisableCoolingOverride_On403(t *testing. } } +func TestManager_MarkResult_CloudflareChallenge_On403(t *testing.T) { + prev := quotaCooldownDisabled.Load() + quotaCooldownDisabled.Store(false) + t.Cleanup(func() { quotaCooldownDisabled.Store(prev) }) + + m := NewManager(nil, nil, nil) + + auth := &Auth{ + ID: "auth-cf-403", + Provider: "claude", + } + if _, errRegister := m.Register(context.Background(), auth); errRegister != nil { + t.Fatalf("register auth: %v", errRegister) + } + + model := "test-model-cf-403" + reg := registry.GetGlobalRegistry() + reg.RegisterClient(auth.ID, "claude", []*registry.ModelInfo{{ID: model}}) + t.Cleanup(func() { reg.UnregisterClient(auth.ID) }) + + m.MarkResult(context.Background(), Result{ + AuthID: auth.ID, + Provider: "claude", + Model: model, + Success: false, + Error: &Error{HTTPStatus: http.StatusForbidden, Message: "cf-mitigated: challenge"}, + }) + + updated, ok := m.GetByID(auth.ID) + if !ok || updated == nil { + t.Fatalf("expected auth to be present") + } + state := updated.ModelStates[model] + if state == nil { + t.Fatalf("expected model state to be present") + } + if state.NextRetryAfter.IsZero() { + t.Fatalf("expected NextRetryAfter to be non-zero for cloudflare challenge") + } + diff := time.Until(state.NextRetryAfter) + if diff < 5*time.Second || diff > 25*time.Second { + t.Fatalf("expected NextRetryAfter to be ~10 seconds, got %v", diff) + } + if state.StatusMessage != "cloudflare challenge" { + t.Fatalf("expected StatusMessage to be 'cloudflare challenge', got %s", state.StatusMessage) + } + + // Because Cloudflare Challenge is treated as transient (no suspension), + // the model should NOT be suspended in the global registry, so count > 0. + if count := reg.GetModelCount(model); count <= 0 { + t.Fatalf("expected model count > 0 for cloudflare challenge transient cooldown, got %d", count) + } +} + func TestManager_Execute_DisableCooling_DoesNotBlackoutAfter403(t *testing.T) { prev := quotaCooldownDisabled.Load() quotaCooldownDisabled.Store(false) -- 2.51.2 From 77061aad4ba9b4ebd8ccea20f421e3006129af2f Mon Sep 17 00:00:00 2001 From: sususu98 Date: Wed, 3 Jun 2026 10:35:39 +0800 Subject: [PATCH 2/2] refactor(auth): simplify and narrow cloudflare challenge checks --- sdk/cliproxy/auth/conductor.go | 43 ++++++++++++++-------------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 76c2a7ae..2d355d48 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -2352,18 +2352,7 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) { suspendReason = "model_not_supported" shouldSuspendModel = true } else if isCloudflareChallengeResultError(result.Error) { - var next time.Time - backoffLevel := state.Quota.BackoffLevel - if !disableCooling { - cooldown, nextLevel := nextQuotaCooldown(backoffLevel, disableCooling) - if cooldown < 10*time.Second { - cooldown = 10 * time.Second - } - if cooldown > 0 { - next = now.Add(cooldown) - } - backoffLevel = nextLevel - } + next, backoffLevel := nextCloudflareCooldown(state.Quota.BackoffLevel, disableCooling, now) state.NextRetryAfter = next state.StatusMessage = "cloudflare challenge" if auth.LastError != nil { @@ -2778,7 +2767,7 @@ func isCloudflareChallengeErrorMessage(message string) bool { lower := strings.ToLower(strings.TrimSpace(message)) return strings.Contains(lower, "challenge-platform") || strings.Contains(lower, "cf-mitigated") || - strings.Contains(lower, "challenge") || + strings.Contains(lower, "cloudflare challenge") || (strings.Contains(lower, "cloudflare") && strings.Contains(lower, " 0 { + next = now.Add(cooldown) + } + backoffLevel = nextLevel + } + return next, backoffLevel +} func isRequestScopedNotFoundMessage(message string) bool { if message == "" { return false @@ -2868,18 +2872,7 @@ func applyAuthFailureState(auth *Auth, resultErr *Error, retryAfter *time.Durati statusCode := statusCodeFromResult(resultErr) if isCloudflareChallengeResultError(resultErr) { auth.StatusMessage = "cloudflare challenge" - var next time.Time - backoffLevel := auth.Quota.BackoffLevel - if !disableCooling { - cooldown, nextLevel := nextQuotaCooldown(backoffLevel, disableCooling) - if cooldown < 10*time.Second { - cooldown = 10 * time.Second - } - if cooldown > 0 { - next = now.Add(cooldown) - } - backoffLevel = nextLevel - } + next, backoffLevel := nextCloudflareCooldown(auth.Quota.BackoffLevel, disableCooling, now) auth.Quota = QuotaState{ Exceeded: true, Reason: "cloudflare challenge", -- 2.51.2