261 lines
11 KiB
Go
261 lines
11 KiB
Go
package dispatch
|
|
|
|
import (
|
|
"math/rand"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func retryPol() RetryPolicy { return DefaultRoutingPolicy().Retry }
|
|
|
|
func TestDecide_HardRulesTakePrecedence(t *testing.T) {
|
|
p := retryPol()
|
|
|
|
// 已向客户端写出响应:任何类别都终止
|
|
for _, cls := range []ErrorClass{ErrClassTransient, ErrClassRateLimit, ErrClassCredential, ErrClassChannelFatal, ErrClassModelFatal, ErrClassTimeout} {
|
|
d, _ := Decide(cls, DeliveryResponseStarted, ReplaySafe, 0, AttemptState{}, p)
|
|
assert.Equal(t, DecisionAbort, d, "ResponseStarted 必须 Abort: %s", cls)
|
|
|
|
d, _ = Decide(cls, DeliveryResponseReceived, ReplaySafe, 0, AttemptState{ResponseStarted: true}, p)
|
|
assert.Equal(t, DecisionAbort, d, "state.ResponseStarted 必须 Abort: %s", cls)
|
|
}
|
|
|
|
// ReplayUnsafe + MaybeSent 无条件 Abort(状态码/类别无关)
|
|
for _, cls := range []ErrorClass{ErrClassTransient, ErrClassRateLimit, ErrClassCredential, ErrClassChannelFatal, ErrClassModelFatal, ErrClassTimeout} {
|
|
d, _ := Decide(cls, DeliveryMaybeSent, ReplayUnsafe, 0, AttemptState{HasAlternateKey: true}, p)
|
|
assert.Equal(t, DecisionAbort, d, "ReplayUnsafe+MaybeSent 必须 Abort: %s", cls)
|
|
}
|
|
|
|
// 总时限耗尽
|
|
d, _ := Decide(ErrClassTransient, DeliveryResponseReceived, ReplaySafe, 0, AttemptState{ElapsedMs: 30_000}, p)
|
|
assert.Equal(t, DecisionAbort, d)
|
|
}
|
|
|
|
func TestDecide_ClientErrorNoRetry(t *testing.T) {
|
|
p := retryPol()
|
|
d, _ := Decide(ErrClassClient, DeliveryResponseReceived, ReplaySafe, 0, AttemptState{}, p)
|
|
assert.Equal(t, DecisionAbort, d)
|
|
d, _ = Decide(ErrClassNone, DeliveryResponseReceived, ReplaySafe, 0, AttemptState{}, p)
|
|
assert.Equal(t, DecisionAbort, d)
|
|
}
|
|
|
|
func TestDecide_TransientInPlaceRetryWithBackoff(t *testing.T) {
|
|
p := retryPol() // inPlaceBudget=2, base=100ms, max=1000ms
|
|
|
|
d, backoff := Decide(ErrClassTransient, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{InPlaceUsed: 0}, p)
|
|
assert.Equal(t, DecisionInPlaceRetry, d)
|
|
assert.Equal(t, int64(100), backoff)
|
|
|
|
d, backoff = Decide(ErrClassTransient, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{InPlaceUsed: 1}, p)
|
|
assert.Equal(t, DecisionInPlaceRetry, d)
|
|
assert.Equal(t, int64(200), backoff, "指数退避 100→200")
|
|
|
|
// 原地预算耗尽 → failover
|
|
d, _ = Decide(ErrClassTransient, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{InPlaceUsed: 2}, p)
|
|
assert.Equal(t, DecisionFailover, d)
|
|
|
|
// failover 预算也耗尽 → Abort
|
|
d, _ = Decide(ErrClassTransient, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{InPlaceUsed: 2, FailoverUsed: 2}, p)
|
|
assert.Equal(t, DecisionAbort, d)
|
|
}
|
|
|
|
func TestDecide_MaybeSentForbidsInPlace(t *testing.T) {
|
|
p := retryPol()
|
|
// TRANSIENT + MaybeSent:可能已送达,原地重发有重复风险 → 直接 failover
|
|
d, _ := Decide(ErrClassTransient, DeliveryMaybeSent, ReplayCostly, 0, AttemptState{}, p)
|
|
assert.Equal(t, DecisionFailover, d)
|
|
}
|
|
|
|
func TestDecide_TimeoutNoInPlaceRetry(t *testing.T) {
|
|
p := retryPol()
|
|
// TIMEOUT(含 504)不原地重试
|
|
d, _ := Decide(ErrClassTimeout, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{}, p)
|
|
assert.Equal(t, DecisionFailover, d)
|
|
|
|
d, _ = Decide(ErrClassTimeout, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{FailoverUsed: 2}, p)
|
|
assert.Equal(t, DecisionAbort, d)
|
|
|
|
// ReplayUnsafe 的超时(未送达除外)直接 Abort:默认 unsafe failover 预算为 0
|
|
d, _ = Decide(ErrClassTimeout, DeliveryResponseReceived, ReplayUnsafe, 0, AttemptState{}, p)
|
|
assert.Equal(t, DecisionAbort, d)
|
|
}
|
|
|
|
func TestDecide_RateLimitRetryAfter(t *testing.T) {
|
|
p := retryPol() // rateLimitWaitMax=2000ms
|
|
|
|
// Retry-After 短 → 原地等待,退避 = Retry-After
|
|
d, backoff := Decide(ErrClassRateLimit, DeliveryResponseReceived, ReplayCostly, 1500, AttemptState{}, p)
|
|
assert.Equal(t, DecisionInPlaceRetry, d)
|
|
assert.Equal(t, int64(1500), backoff)
|
|
|
|
// Retry-After 超限 → failover
|
|
d, _ = Decide(ErrClassRateLimit, DeliveryResponseReceived, ReplayCostly, 5000, AttemptState{}, p)
|
|
assert.Equal(t, DecisionFailover, d)
|
|
|
|
// 无 Retry-After → failover
|
|
d, _ = Decide(ErrClassRateLimit, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{}, p)
|
|
assert.Equal(t, DecisionFailover, d)
|
|
}
|
|
|
|
func TestDecide_CredentialRotation(t *testing.T) {
|
|
p := retryPol() // credRotateBudget=1
|
|
|
|
// 有备用 Key 且预算未耗尽 → 轮换
|
|
d, backoff := Decide(ErrClassCredential, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{HasAlternateKey: true}, p)
|
|
assert.Equal(t, DecisionRotateCredential, d)
|
|
assert.Zero(t, backoff)
|
|
|
|
// 无备用 Key → 升级 failover(等价渠道级致命)
|
|
d, _ = Decide(ErrClassCredential, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{HasAlternateKey: false}, p)
|
|
assert.Equal(t, DecisionFailover, d)
|
|
|
|
// 轮换预算耗尽 → failover
|
|
d, _ = Decide(ErrClassCredential, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{HasAlternateKey: true, CredRotationsUsed: 1}, p)
|
|
assert.Equal(t, DecisionFailover, d)
|
|
|
|
// failover 预算也耗尽 → Abort
|
|
d, _ = Decide(ErrClassCredential, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{CredRotationsUsed: 1, FailoverUsed: 2}, p)
|
|
assert.Equal(t, DecisionAbort, d)
|
|
}
|
|
|
|
func TestDecide_ChannelFatalZeroInPlace(t *testing.T) {
|
|
p := retryPol()
|
|
d, _ := Decide(ErrClassChannelFatal, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{}, p)
|
|
assert.Equal(t, DecisionFailover, d)
|
|
}
|
|
|
|
func TestDecide_ModelFatalFailsOverDirectly(t *testing.T) {
|
|
p := retryPol()
|
|
// 模型致命与渠道致命同语义:零原地重试、立即 failover
|
|
d, _ := Decide(ErrClassModelFatal, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{}, p)
|
|
assert.Equal(t, DecisionFailover, d)
|
|
|
|
// failover 预算耗尽 → Abort
|
|
d, _ = Decide(ErrClassModelFatal, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{FailoverUsed: 2}, p)
|
|
assert.Equal(t, DecisionAbort, d)
|
|
}
|
|
|
|
func TestDecide_ReplayUnsafeTightensBudget(t *testing.T) {
|
|
p := retryPol() // unsafe 预算默认 0/0
|
|
|
|
// 已收到错误响应(非 NotSent)→ Abort
|
|
d, _ := Decide(ErrClassTransient, DeliveryResponseReceived, ReplayUnsafe, 0, AttemptState{}, p)
|
|
assert.Equal(t, DecisionAbort, d)
|
|
|
|
// 明确未送达但预算为 0 → 仍 Abort
|
|
d, _ = Decide(ErrClassTransient, DeliveryNotSent, ReplayUnsafe, 0, AttemptState{}, p)
|
|
assert.Equal(t, DecisionAbort, d)
|
|
|
|
// 放宽 unsafe failover 预算后,仅 NotSent 允许 failover
|
|
p.UnsafeFailoverBudget = 1
|
|
d, _ = Decide(ErrClassTransient, DeliveryNotSent, ReplayUnsafe, 0, AttemptState{}, p)
|
|
assert.Equal(t, DecisionFailover, d)
|
|
d, _ = Decide(ErrClassTransient, DeliveryResponseReceived, ReplayUnsafe, 0, AttemptState{}, p)
|
|
assert.Equal(t, DecisionAbort, d, "非 NotSent 即使有预算也不允许 failover")
|
|
}
|
|
|
|
// TestDecide_Property_AnyErrorSequenceStaysWithinBudget 属性测试:模拟协调器的预算记账,
|
|
// 用随机错误序列驱动 FSM,断言任何序列都不会突破预算且必然终止。
|
|
func TestDecide_Property_AnyErrorSequenceStaysWithinBudget(t *testing.T) {
|
|
p := retryPol()
|
|
classes := []ErrorClass{ErrClassClient, ErrClassTransient, ErrClassRateLimit, ErrClassCredential, ErrClassChannelFatal, ErrClassModelFatal, ErrClassTimeout}
|
|
deliveries := []DeliveryState{DeliveryNotSent, DeliveryMaybeSent, DeliveryResponseReceived}
|
|
replays := []Replayability{ReplaySafe, ReplayCostly, ReplayUnsafe}
|
|
|
|
rng := rand.New(rand.NewSource(42))
|
|
for run := range 5000 {
|
|
replay := replays[rng.Intn(len(replays))]
|
|
s := AttemptState{}
|
|
steps := 0
|
|
for {
|
|
steps++
|
|
require.Less(t, steps, 50, "run %d: FSM 必须在有限步内终止", run)
|
|
|
|
cls := classes[rng.Intn(len(classes))]
|
|
delivery := deliveries[rng.Intn(len(deliveries))]
|
|
s.HasAlternateKey = rng.Intn(2) == 0
|
|
retryAfter := int64(rng.Intn(4000))
|
|
|
|
d, _ := Decide(cls, delivery, replay, retryAfter, s, p)
|
|
switch d {
|
|
case DecisionAbort:
|
|
// 终止
|
|
case DecisionInPlaceRetry:
|
|
s.InPlaceUsed++
|
|
require.LessOrEqual(t, s.InPlaceUsed, p.InPlaceBudget, "原地预算不得突破")
|
|
continue
|
|
case DecisionRotateCredential:
|
|
s.CredRotationsUsed++
|
|
require.LessOrEqual(t, s.CredRotationsUsed, p.CredRotateBudget, "凭证轮换预算不得突破")
|
|
continue
|
|
case DecisionFailover:
|
|
s.FailoverUsed++
|
|
require.LessOrEqual(t, s.FailoverUsed, p.FailoverBudget, "failover 预算不得突破")
|
|
// 换渠道:渠道级计数清零(与协调器行为一致)
|
|
s.InPlaceUsed = 0
|
|
s.CredRotationsUsed = 0
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBackoffMs_Capped(t *testing.T) {
|
|
p := retryPol() // base=100, max=1000
|
|
assert.Equal(t, int64(100), backoffMs(0, p))
|
|
assert.Equal(t, int64(200), backoffMs(1, p))
|
|
assert.Equal(t, int64(400), backoffMs(2, p))
|
|
assert.Equal(t, int64(800), backoffMs(3, p))
|
|
assert.Equal(t, int64(1000), backoffMs(4, p), "封顶")
|
|
assert.Equal(t, int64(1000), backoffMs(60, p), "大次数不溢出")
|
|
}
|
|
|
|
func TestRoutingPolicy_DefaultsAreValid(t *testing.T) {
|
|
assert.NoError(t, DefaultRoutingPolicy().Validate())
|
|
}
|
|
|
|
func TestRoutingPolicy_ValidateRejectsInvalidConfig(t *testing.T) {
|
|
mutate := func(f func(*RoutingPolicy)) *RoutingPolicy {
|
|
p := DefaultRoutingPolicy()
|
|
f(p)
|
|
return p
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
pol *RoutingPolicy
|
|
}{
|
|
{"nil", nil},
|
|
{"tierFactors 为空", mutate(func(p *RoutingPolicy) { p.TierFactors = nil })},
|
|
{"未知层级", mutate(func(p *RoutingPolicy) { p.TierFactors[Tier("vip")] = 1 })},
|
|
{"负层级因子", mutate(func(p *RoutingPolicy) { p.TierFactors[TierSecondary] = -0.1 })},
|
|
{"primary 因子为 0", mutate(func(p *RoutingPolicy) { p.TierFactors[TierPrimary] = 0 })},
|
|
{"alpha 非正", mutate(func(p *RoutingPolicy) { p.Health.Alpha = 0 })},
|
|
{"gamma 为负", mutate(func(p *RoutingPolicy) { p.Load.Gamma = -1 })},
|
|
{"cost max < min", mutate(func(p *RoutingPolicy) { p.Cost.Max = 0.1 })},
|
|
{"ramp floor 越界", mutate(func(p *RoutingPolicy) { p.Ramp.Floor = 1.5 })},
|
|
{"绑定 TTL 非正", mutate(func(p *RoutingPolicy) { p.Binding.TTLSeconds = 0 })},
|
|
{"守卫阈值越界", mutate(func(p *RoutingPolicy) { p.Binding.KeepHealthMin = 2 })},
|
|
{"负预算", mutate(func(p *RoutingPolicy) { p.Retry.FailoverBudget = -1 })},
|
|
{"退避上限小于基数", mutate(func(p *RoutingPolicy) { p.Retry.BackoffMaxMs = 10 })},
|
|
{"熔断阈值非正", mutate(func(p *RoutingPolicy) { p.Breaker.FailThreshold = 0 })},
|
|
{"模型级熔断阈值非正", mutate(func(p *RoutingPolicy) { p.Breaker.ModelFailThreshold = 0 })},
|
|
{"冷却上限小于起始", mutate(func(p *RoutingPolicy) { p.Breaker.CooldownMaxSeconds = 1 })},
|
|
{"副本数非正", mutate(func(p *RoutingPolicy) { p.Degrade.MaxReplicas = 0 })},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Error(t, tt.pol.Validate())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRetryDecision_String(t *testing.T) {
|
|
assert.Equal(t, "inplace", DecisionInPlaceRetry.String())
|
|
assert.Equal(t, "cred_rotate", DecisionRotateCredential.String())
|
|
assert.Equal(t, "failover", DecisionFailover.String())
|
|
assert.Equal(t, "abort", DecisionAbort.String())
|
|
}
|