Merge pull request #3689 from sususu98/pr/cloudflare-challenge-retry

fix(auth): retry and backoff cloudflare challenge 403 errors
This commit is contained in:
Luis Pater
2026-06-03 11:07:08 +08:00
committed by GitHub
2 changed files with 118 additions and 0 deletions
+64
View File
@@ -2351,6 +2351,19 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
state.NextRetryAfter = next
suspendReason = "model_not_supported"
shouldSuspendModel = true
} else if isCloudflareChallengeResultError(result.Error) {
next, backoffLevel := nextCloudflareCooldown(state.Quota.BackoffLevel, disableCooling, now)
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 +2763,42 @@ 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, "cloudflare challenge") ||
(strings.Contains(lower, "cloudflare") && strings.Contains(lower, "<html"))
}
func isCloudflareChallengeError(err error) bool {
if err == nil {
return false
}
return isCloudflareChallengeErrorMessage(err.Error())
}
func isCloudflareChallengeResultError(err *Error) bool {
if err == nil {
return false
}
return isCloudflareChallengeErrorMessage(err.Message)
}
func nextCloudflareCooldown(backoffLevel int, disableCooling bool, now time.Time) (time.Time, int) {
var next time.Time
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
}
return next, backoffLevel
}
func isRequestScopedNotFoundMessage(message string) bool {
if message == "" {
return false
@@ -2777,6 +2826,9 @@ func isRequestInvalidError(err error) bool {
if err == nil {
return false
}
if isCloudflareChallengeError(err) {
return false
}
if isModelSupportError(err) {
return false
}
@@ -2818,6 +2870,18 @@ func applyAuthFailureState(auth *Auth, resultErr *Error, retryAfter *time.Durati
}
}
statusCode := statusCodeFromResult(resultErr)
if isCloudflareChallengeResultError(resultErr) {
auth.StatusMessage = "cloudflare challenge"
next, backoffLevel := nextCloudflareCooldown(auth.Quota.BackoffLevel, disableCooling, now)
auth.Quota = QuotaState{
Exceeded: true,
Reason: "cloudflare challenge",
NextRecoverAt: next,
BackoffLevel: backoffLevel,
}
auth.NextRetryAfter = next
return
}
switch statusCode {
case 401:
auth.StatusMessage = "unauthorized"
@@ -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)