Skip to content

Commit 1c9601f

Browse files
authored
Merge pull request router-for-me#3689 from sususu98/pr/cloudflare-challenge-retry
fix(auth): retry and backoff cloudflare challenge 403 errors
2 parents 7682acc + 77061aa commit 1c9601f

2 files changed

Lines changed: 118 additions & 0 deletions

File tree

sdk/cliproxy/auth/conductor.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2351,6 +2351,19 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
23512351
state.NextRetryAfter = next
23522352
suspendReason = "model_not_supported"
23532353
shouldSuspendModel = true
2354+
} else if isCloudflareChallengeResultError(result.Error) {
2355+
next, backoffLevel := nextCloudflareCooldown(state.Quota.BackoffLevel, disableCooling, now)
2356+
state.NextRetryAfter = next
2357+
state.StatusMessage = "cloudflare challenge"
2358+
if auth.LastError != nil {
2359+
auth.StatusMessage = "cloudflare challenge"
2360+
}
2361+
state.Quota = QuotaState{
2362+
Exceeded: true,
2363+
Reason: "cloudflare challenge",
2364+
NextRecoverAt: next,
2365+
BackoffLevel: backoffLevel,
2366+
}
23542367
} else {
23552368
switch statusCode {
23562369
case 401:
@@ -2750,6 +2763,42 @@ func isModelSupportResultError(err *Error) bool {
27502763
return isModelSupportErrorMessage(err.Message)
27512764
}
27522765

2766+
func isCloudflareChallengeErrorMessage(message string) bool {
2767+
lower := strings.ToLower(strings.TrimSpace(message))
2768+
return strings.Contains(lower, "challenge-platform") ||
2769+
strings.Contains(lower, "cf-mitigated") ||
2770+
strings.Contains(lower, "cloudflare challenge") ||
2771+
(strings.Contains(lower, "cloudflare") && strings.Contains(lower, "<html"))
2772+
}
2773+
2774+
func isCloudflareChallengeError(err error) bool {
2775+
if err == nil {
2776+
return false
2777+
}
2778+
return isCloudflareChallengeErrorMessage(err.Error())
2779+
}
2780+
2781+
func isCloudflareChallengeResultError(err *Error) bool {
2782+
if err == nil {
2783+
return false
2784+
}
2785+
return isCloudflareChallengeErrorMessage(err.Message)
2786+
}
2787+
2788+
func nextCloudflareCooldown(backoffLevel int, disableCooling bool, now time.Time) (time.Time, int) {
2789+
var next time.Time
2790+
if !disableCooling {
2791+
cooldown, nextLevel := nextQuotaCooldown(backoffLevel, disableCooling)
2792+
if cooldown < 10*time.Second {
2793+
cooldown = 10 * time.Second
2794+
}
2795+
if cooldown > 0 {
2796+
next = now.Add(cooldown)
2797+
}
2798+
backoffLevel = nextLevel
2799+
}
2800+
return next, backoffLevel
2801+
}
27532802
func isRequestScopedNotFoundMessage(message string) bool {
27542803
if message == "" {
27552804
return false
@@ -2777,6 +2826,9 @@ func isRequestInvalidError(err error) bool {
27772826
if err == nil {
27782827
return false
27792828
}
2829+
if isCloudflareChallengeError(err) {
2830+
return false
2831+
}
27802832
if isModelSupportError(err) {
27812833
return false
27822834
}
@@ -2818,6 +2870,18 @@ func applyAuthFailureState(auth *Auth, resultErr *Error, retryAfter *time.Durati
28182870
}
28192871
}
28202872
statusCode := statusCodeFromResult(resultErr)
2873+
if isCloudflareChallengeResultError(resultErr) {
2874+
auth.StatusMessage = "cloudflare challenge"
2875+
next, backoffLevel := nextCloudflareCooldown(auth.Quota.BackoffLevel, disableCooling, now)
2876+
auth.Quota = QuotaState{
2877+
Exceeded: true,
2878+
Reason: "cloudflare challenge",
2879+
NextRecoverAt: next,
2880+
BackoffLevel: backoffLevel,
2881+
}
2882+
auth.NextRetryAfter = next
2883+
return
2884+
}
28212885
switch statusCode {
28222886
case 401:
28232887
auth.StatusMessage = "unauthorized"

sdk/cliproxy/auth/conductor_overrides_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,60 @@ func TestManager_MarkResult_RespectsAuthDisableCoolingOverride_On403(t *testing.
570570
}
571571
}
572572

573+
func TestManager_MarkResult_CloudflareChallenge_On403(t *testing.T) {
574+
prev := quotaCooldownDisabled.Load()
575+
quotaCooldownDisabled.Store(false)
576+
t.Cleanup(func() { quotaCooldownDisabled.Store(prev) })
577+
578+
m := NewManager(nil, nil, nil)
579+
580+
auth := &Auth{
581+
ID: "auth-cf-403",
582+
Provider: "claude",
583+
}
584+
if _, errRegister := m.Register(context.Background(), auth); errRegister != nil {
585+
t.Fatalf("register auth: %v", errRegister)
586+
}
587+
588+
model := "test-model-cf-403"
589+
reg := registry.GetGlobalRegistry()
590+
reg.RegisterClient(auth.ID, "claude", []*registry.ModelInfo{{ID: model}})
591+
t.Cleanup(func() { reg.UnregisterClient(auth.ID) })
592+
593+
m.MarkResult(context.Background(), Result{
594+
AuthID: auth.ID,
595+
Provider: "claude",
596+
Model: model,
597+
Success: false,
598+
Error: &Error{HTTPStatus: http.StatusForbidden, Message: "cf-mitigated: challenge"},
599+
})
600+
601+
updated, ok := m.GetByID(auth.ID)
602+
if !ok || updated == nil {
603+
t.Fatalf("expected auth to be present")
604+
}
605+
state := updated.ModelStates[model]
606+
if state == nil {
607+
t.Fatalf("expected model state to be present")
608+
}
609+
if state.NextRetryAfter.IsZero() {
610+
t.Fatalf("expected NextRetryAfter to be non-zero for cloudflare challenge")
611+
}
612+
diff := time.Until(state.NextRetryAfter)
613+
if diff < 5*time.Second || diff > 25*time.Second {
614+
t.Fatalf("expected NextRetryAfter to be ~10 seconds, got %v", diff)
615+
}
616+
if state.StatusMessage != "cloudflare challenge" {
617+
t.Fatalf("expected StatusMessage to be 'cloudflare challenge', got %s", state.StatusMessage)
618+
}
619+
620+
// Because Cloudflare Challenge is treated as transient (no suspension),
621+
// the model should NOT be suspended in the global registry, so count > 0.
622+
if count := reg.GetModelCount(model); count <= 0 {
623+
t.Fatalf("expected model count > 0 for cloudflare challenge transient cooldown, got %d", count)
624+
}
625+
}
626+
573627
func TestManager_Execute_DisableCooling_DoesNotBlackoutAfter403(t *testing.T) {
574628
prev := quotaCooldownDisabled.Load()
575629
quotaCooldownDisabled.Store(false)

0 commit comments

Comments
 (0)