refactor(runner): 收敛上游失败决策与轮转策略
将同平台重试、跨平台动作、健康副作用和冷却排队统一到单一失败决策,避免旧降级策略与 failover 重复执行。\n\n增加事务级单源保护、候选实时刷新、冷却错误契约、管理接口严格校验及兼容策略只读展示。\n\n验证:真实 Gateway HTTP/PostgreSQL 接受测试 10 项通过;go test ./...、pnpm openapi、pnpm lint、pnpm test、pnpm build 均通过。
This commit is contained in:
@@ -334,3 +334,151 @@ func TestPriorityDemotePolicyIsSkippedWhenFailoverActionRulesExist(t *testing.T)
|
||||
t.Fatalf("legacy priority demotion should be skipped when actionRules are active, got %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCandidateFailureCooldownSkipsSameClientRetry(t *testing.T) {
|
||||
decision := resolveCandidateFailure(resolveCandidateFailureInput{
|
||||
RunnerPolicy: store.RunnerPolicy{
|
||||
Status: "active",
|
||||
FailoverPolicy: map[string]any{
|
||||
"enabled": true,
|
||||
"actionRules": []any{
|
||||
map[string]any{
|
||||
"statusCodes": []any{429},
|
||||
"action": "cooldown_and_next",
|
||||
"target": "model",
|
||||
"cooldownSeconds": 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
ModelRetryPolicy: map[string]any{"enabled": true, "maxAttempts": 3},
|
||||
},
|
||||
Err: &clients.ClientError{Code: "resource_exhausted", StatusCode: 429, Retryable: true},
|
||||
ClientAttempt: 1,
|
||||
MaxClientAttempts: 3,
|
||||
HasNextCandidate: true,
|
||||
})
|
||||
|
||||
if decision.Route != "next" || decision.Effect != "cooldown" || decision.Target != "model" || decision.CooldownSeconds != 60 {
|
||||
t.Fatalf("429 cooldown rule should invalidate the candidate immediately, got %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCandidateFailureDemoteRetriesBeforeNext(t *testing.T) {
|
||||
input := resolveCandidateFailureInput{
|
||||
RunnerPolicy: store.RunnerPolicy{
|
||||
Status: "active",
|
||||
FailoverPolicy: map[string]any{
|
||||
"enabled": true,
|
||||
"actionRules": []any{
|
||||
map[string]any{
|
||||
"statusCodes": []any{500},
|
||||
"action": "demote_and_next",
|
||||
"target": "platform",
|
||||
"demoteSteps": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
ModelRetryPolicy: map[string]any{"enabled": true, "maxAttempts": 2},
|
||||
},
|
||||
Err: &clients.ClientError{Code: "server_error", StatusCode: 500, Retryable: true},
|
||||
ClientAttempt: 1,
|
||||
MaxClientAttempts: 2,
|
||||
HasNextCandidate: true,
|
||||
}
|
||||
|
||||
first := resolveCandidateFailure(input)
|
||||
if first.Route != "retry_same" || first.Effect != "none" {
|
||||
t.Fatalf("demotion must wait until same-client retries are exhausted, got %+v", first)
|
||||
}
|
||||
|
||||
input.ClientAttempt = 2
|
||||
second := resolveCandidateFailure(input)
|
||||
if second.Route != "next" || second.Effect != "demote" || second.DemoteSteps != 2 {
|
||||
t.Fatalf("exhausted retry should demote once and rotate, got %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCandidateFailureStopsAfterDownstreamStarts(t *testing.T) {
|
||||
decision := resolveCandidateFailure(resolveCandidateFailureInput{
|
||||
RunnerPolicy: store.RunnerPolicy{
|
||||
Status: "active",
|
||||
FailoverPolicy: map[string]any{
|
||||
"enabled": true,
|
||||
"actionRules": []any{
|
||||
map[string]any{
|
||||
"categories": []any{"stream_error"},
|
||||
"action": "next",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{ModelRetryPolicy: map[string]any{"enabled": true, "maxAttempts": 3}},
|
||||
Err: &clients.ClientError{Code: "stream_read_error", Retryable: true},
|
||||
ClientAttempt: 1,
|
||||
MaxClientAttempts: 3,
|
||||
HasNextCandidate: true,
|
||||
DownstreamStarted: true,
|
||||
})
|
||||
|
||||
if decision.Route != "stop" || decision.Reason != "downstream_response_started" {
|
||||
t.Fatalf("stream output must prevent retries and failover, got %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCandidateFailureLegacyPolicyPrecedence(t *testing.T) {
|
||||
candidate := store.RuntimeModelCandidate{
|
||||
DegradePolicy: map[string]any{
|
||||
"enabled": true,
|
||||
"keywords": []any{"resource_exhausted"},
|
||||
"cooldownSeconds": 45,
|
||||
},
|
||||
}
|
||||
decision := resolveCandidateFailure(resolveCandidateFailureInput{
|
||||
RunnerPolicy: store.RunnerPolicy{
|
||||
Status: "active",
|
||||
FailoverPolicy: map[string]any{
|
||||
"enabled": true,
|
||||
"actionRules": []any{
|
||||
map[string]any{
|
||||
"statusCodes": []any{429},
|
||||
"action": "cooldown_and_next",
|
||||
"target": "platform",
|
||||
"cooldownSeconds": 90,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Candidate: candidate,
|
||||
Err: &clients.ClientError{Code: "resource_exhausted", Message: "resource_exhausted", StatusCode: 429, Retryable: true},
|
||||
ClientAttempt: 1,
|
||||
MaxClientAttempts: 3,
|
||||
HasNextCandidate: true,
|
||||
})
|
||||
|
||||
if decision.Match.Policy != "degradePolicy" || decision.Target != "model" || decision.CooldownSeconds != 45 {
|
||||
t.Fatalf("legacy compatibility rule should precede global action rules, got %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCandidateFailureUnmatchedRetryableUsesSameThenNext(t *testing.T) {
|
||||
input := resolveCandidateFailureInput{
|
||||
RunnerPolicy: store.RunnerPolicy{Status: "active", FailoverPolicy: map[string]any{"enabled": true, "actionRules": []any{}}},
|
||||
Candidate: store.RuntimeModelCandidate{ModelRetryPolicy: map[string]any{"enabled": true, "maxAttempts": 2}},
|
||||
Err: &clients.ClientError{Code: "unknown_transient", Retryable: true},
|
||||
ClientAttempt: 1,
|
||||
MaxClientAttempts: 2,
|
||||
HasNextCandidate: true,
|
||||
}
|
||||
|
||||
if decision := resolveCandidateFailure(input); decision.Route != "retry_same" {
|
||||
t.Fatalf("unmatched retryable error should retry the same client first, got %+v", decision)
|
||||
}
|
||||
input.ClientAttempt = 2
|
||||
if decision := resolveCandidateFailure(input); decision.Route != "next" || decision.Effect != "none" {
|
||||
t.Fatalf("unmatched exhausted error should rotate without side effects, got %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user