feat: suppress consecutive-channel opening on evidently healthy sources

Round 8 forensics caught the healthy source circuit-opened by five
random empty completions (~20% ambient failure rate makes a 5-streak
land every ~3000 attempts), blacking out the only good source for 60s.
When the window holds min_calls samples below the failure-rate
threshold, a streak is noise and no longer opens the gate; cold-start
and low-traffic semantics are unchanged and sudden death of a warm
source is still caught by the rate channel.
This commit is contained in:
2026-07-21 15:28:34 -04:00
parent 0e0d599441
commit 121888a0eb
4 changed files with 60 additions and 5 deletions
+33
View File
@@ -290,3 +290,36 @@ class TestRetryAfter:
gate = gate_factory(_CFG)
await _open_gate(gate, "s1") # s1 开路;s2 健康
assert await gate.retry_after_s(("s1", "s2")) == 0.0
class TestConsecutiveSuppression:
"""迭代 6: 窗口证据充足且健康时,连败是噪声,不开路(设计 §3.39)。"""
async def test_streak_suppressed_on_evidently_healthy_source(self, gate_factory):
# 20 成功垫底(窗口样本充足、失败率低)后 3 连败(阈值 3)→ 不开路
cfg = BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0, min_calls=10)
gate = gate_factory(cfg)
for _ in range(20):
entry = await gate.try_enter("s1", "w")
await gate.record_success(entry)
await _fail(gate, reason="timeout", n=3)
assert (await gate.try_enter("s1", "w")).allowed
async def test_streak_fires_when_window_insufficient(self, gate_factory):
# 冷启动(窗口样本不足)3 连败照常开路——连续通道本职保留
cfg = BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0, min_calls=10)
gate = gate_factory(cfg)
update = await _fail(gate, reason="timeout", n=3)
assert update.state is GateState.OPEN
async def test_warm_source_going_dead_caught_by_rate(self, gate_factory):
# 温热源猝死: 连败被抑制,但失败率窗口随失败累积必然接管
cfg = BreakerConfig(
fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0, min_calls=4, fail_rate=0.6
)
gate = gate_factory(cfg)
for _ in range(3):
entry = await gate.try_enter("s1", "w")
await gate.record_success(entry)
update = await _fail(gate, reason="timeout", n=5) # 5/8 = 0.625 ≥ 0.6
assert update.state is GateState.OPEN