Files
sundynix-agentix/sundynix-dispatcher/internal/harness/circuitbreaker_test.go
T
Blizzard 31bf3e5907 feat(dispatcher): 熔断降级真三态状态机(弃用空桩)+ 单测
CircuitBreaker 此前是空桩(Allow 恒 true、Report 空操作),dispatcher 调 LLM/工具
无任何失败保护——今天就撞上 DeepSeek 流连接累积把报告卡死。改为真实三态熔断:

- Closed:正常放行;连续失败达阈值(默认5) → Open。
- Open:快速拒绝;冷却(默认10s)到点 → HalfOpen 放行少量探测(默认1)。
- HalfOpen:探测成功 → Closed 恢复;探测失败 → 重新 Open。
- sync.Mutex 并发安全(多任务 goroutine 共享);时钟可注入便于确定性测试。

orchestrator.Handle:熔断开启时不再静默丢弃任务,改为回流"服务繁忙"提示 +
CompleteStream 收尾,让客户端解阻不挂死。

测试(含 -race):达阈值断开、成功清零、半开恢复、探测失败重断、并发安全 —— 全过。
PROGRESS.md 勾掉熔断项。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:02:45 +08:00

113 lines
2.8 KiB
Go

package harness
import (
"sync"
"testing"
"time"
)
// newTestCB 造一个可控时钟、低阈值的熔断器,便于确定性测试。
func newTestCB(threshold int, cooldown time.Duration, clock *time.Time) *CircuitBreaker {
c := NewCircuitBreaker()
c.threshold = threshold
c.cooldown = cooldown
c.now = func() time.Time { return *clock }
return c
}
func TestCircuitBreaker_OpensAfterThreshold(t *testing.T) {
now := time.Unix(0, 0)
c := newTestCB(3, 10*time.Second, &now)
if !c.Allow() || c.State() != Closed {
t.Fatal("初始应闭合放行")
}
c.Report(false)
c.Report(false)
if c.State() != Closed {
t.Fatal("未达阈值不应断开")
}
c.Report(false) // 第 3 次连续失败 → 断开
if c.State() != Open {
t.Fatalf("达阈值应断开, got %v", c.State())
}
if c.Allow() {
t.Error("断开态应拒绝放行")
}
}
func TestCircuitBreaker_SuccessResetsFails(t *testing.T) {
now := time.Unix(0, 0)
c := newTestCB(3, 10*time.Second, &now)
c.Report(false)
c.Report(false)
c.Report(true) // 成功清零连续失败
c.Report(false)
c.Report(false)
if c.State() != Closed {
t.Errorf("成功应清零计数,不应断开, got %v", c.State())
}
}
func TestCircuitBreaker_HalfOpenRecovers(t *testing.T) {
now := time.Unix(0, 0)
c := newTestCB(2, 10*time.Second, &now)
c.Report(false)
c.Report(false) // 断开
if c.State() != Open || c.Allow() {
t.Fatal("应断开并拒绝")
}
// 冷却未到 → 仍拒绝。
now = now.Add(5 * time.Second)
if c.Allow() {
t.Fatal("冷却未到不应放行")
}
// 冷却到点 → 半开放行一个探测,第二个被拒。
now = now.Add(6 * time.Second)
if !c.Allow() || c.State() != HalfOpen {
t.Fatalf("冷却到点应转半开并放行探测, state=%v", c.State())
}
if c.Allow() {
t.Error("半开态超过探测名额应拒绝")
}
// 探测成功 → 恢复闭合。
c.Report(true)
if c.State() != Closed || !c.Allow() {
t.Errorf("探测成功应恢复闭合, state=%v", c.State())
}
}
func TestCircuitBreaker_HalfOpenProbeFailReopens(t *testing.T) {
now := time.Unix(0, 0)
c := newTestCB(1, 10*time.Second, &now)
c.Report(false) // 阈值 1 → 立即断开
now = now.Add(11 * time.Second)
if !c.Allow() || c.State() != HalfOpen {
t.Fatal("应转半开")
}
c.Report(false) // 探测失败 → 重新断开,冷却从此刻重算
if c.State() != Open {
t.Fatalf("探测失败应重新断开, got %v", c.State())
}
if c.Allow() {
t.Error("重新断开后冷却内应拒绝")
}
}
func TestCircuitBreaker_ConcurrentSafe(t *testing.T) {
now := time.Now()
c := newTestCB(1000000, time.Second, &now)
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 200; j++ {
c.Allow()
c.Report(j%2 == 0)
}
}()
}
wg.Wait() // 仅验证无数据竞争 / 死锁(配合 -race)
}