feat: add in-memory limiter and breaker satisfying backend contracts

This commit is contained in:
2026-07-20 06:54:07 -04:00
parent 454a8b5e0f
commit 4a176b6220
9 changed files with 645 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
"""契约测试共享 fixture: 后端参数化(M1 仅 memory,M2 增 redis 零改测试)。
FakeClock 仅对支持时钟注入的后端有效(memory);M2 接入 Redis 后端时,
依赖时钟推进的用例按后端能力跳过或改用真实等待。
"""
import pytest
from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.types import BreakerConfig, GlobalLimits, SourceConfig
class FakeClock:
"""确定性单调时钟;契约测试推进时间验证租约/冷却语义。"""
def __init__(self, start: float = 1000.0) -> None:
self.t = start
def __call__(self) -> float:
return self.t
def advance(self, seconds: float) -> None:
self.t += seconds
def make_source(name: str = "s1", **overrides) -> SourceConfig:
base = dict(
name=name, provider="openai", base_url="https://gw.example/v1",
api_key="sk-test", model="m", timeout_s=10.0,
)
base.update(overrides)
return SourceConfig(**base)
@pytest.fixture
def clock() -> FakeClock:
return FakeClock()
@pytest.fixture(params=["memory"])
def limiter_factory(request, clock):
"""返回 (sources, global_limits, lease_ttl_s) -> RateLimiter 的工厂。"""
def make(sources: list[SourceConfig], global_limits: GlobalLimits, lease_ttl_s: float = 100.0):
return InMemoryLimiter(
scope="llm", sources={s.name: s for s in sources},
global_limits=global_limits, lease_ttl_s=lease_ttl_s, now=clock,
)
return make
@pytest.fixture(params=["memory"])
def gate_factory(request, clock):
"""返回 (BreakerConfig) -> ProviderGate 的工厂。"""
def make(config: BreakerConfig):
return InMemoryGate(config=config, now=clock)
return make
+151
View File
@@ -0,0 +1,151 @@
"""熔断后端契约测试(状态机 + 半开单探针租约 + epoch fencing)。
蓝本: VT adapters/breaker.py 状态机语义 + CHS provider_gate.py 契约;
M2 的 Redis 实现复用本套件。
"""
import pytest
from polygateway.ports import GateState
from polygateway.types import BreakerConfig
_CFG = BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0)
async def _open_gate(gate, source="s1"):
"""连续失败到阈值,打开熔断,返回最后一次 Update。"""
update = None
for _ in range(_CFG.fail_threshold):
entry = await gate.try_enter(source, "w")
assert entry.allowed
update = await gate.record_failure(entry, "network_error", False)
assert update.state is GateState.OPEN
return update
class TestStateMachine:
async def test_closed_admits_and_success_resets(self, gate_factory):
gate = gate_factory(_CFG)
entry = await gate.try_enter("s1", "w")
assert entry.allowed and entry.state is GateState.CLOSED and not entry.is_probe
update = await gate.record_success(entry)
assert update.applied and update.failure_count == 0
async def test_threshold_opens_and_rejects(self, gate_factory):
gate = gate_factory(_CFG)
await _open_gate(gate)
entry = await gate.try_enter("s1", "w")
assert not entry.allowed and entry.state is GateState.OPEN
assert entry.retry_after_s > 0
async def test_success_before_threshold_resets_count(self, gate_factory):
gate = gate_factory(_CFG)
for _ in range(_CFG.fail_threshold - 1):
entry = await gate.try_enter("s1", "w")
await gate.record_failure(entry, "timeout", False)
entry = await gate.try_enter("s1", "w")
update = await gate.record_success(entry)
assert update.applied and update.failure_count == 0
assert (await gate.try_enter("s1", "w")).allowed
async def test_force_open_single_strike(self, gate_factory):
gate = gate_factory(_CFG)
entry = await gate.try_enter("s1", "w")
update = await gate.record_failure(entry, "source_dead", True)
assert update.state is GateState.OPEN
assert not (await gate.try_enter("s1", "w")).allowed
class TestHalfOpenProbe:
async def test_cooldown_grants_single_probe(self, gate_factory, clock):
gate = gate_factory(_CFG)
await _open_gate(gate)
clock.advance(_CFG.cooldown_s + 1)
probe = await gate.try_enter("s1", "w1")
assert probe.allowed and probe.is_probe and probe.probe_owner == "w1"
# 第二个进入者被拒(防惊群)
second = await gate.try_enter("s1", "w2")
assert not second.allowed and second.state is GateState.HALF_OPEN
async def test_probe_success_closes(self, gate_factory, clock):
gate = gate_factory(_CFG)
await _open_gate(gate)
clock.advance(_CFG.cooldown_s + 1)
probe = await gate.try_enter("s1", "w1")
update = await gate.record_success(probe)
assert update.applied and update.state is GateState.CLOSED
assert (await gate.try_enter("s1", "w2")).allowed
async def test_probe_failure_reopens(self, gate_factory, clock):
gate = gate_factory(_CFG)
await _open_gate(gate)
clock.advance(_CFG.cooldown_s + 1)
probe = await gate.try_enter("s1", "w1")
update = await gate.record_failure(probe, "network_error", False)
assert update.state is GateState.OPEN
assert not (await gate.try_enter("s1", "w2")).allowed
async def test_probe_lease_expiry_allows_takeover(self, gate_factory, clock):
"""探针持有者死亡 → 租约过期后新 caller 接管探针,防死锁。"""
gate = gate_factory(_CFG)
await _open_gate(gate)
clock.advance(_CFG.cooldown_s + 1)
stale = await gate.try_enter("s1", "dead-worker")
assert stale.is_probe
clock.advance(_CFG.probe_ttl_s + 1)
takeover = await gate.try_enter("s1", "w2")
assert takeover.allowed and takeover.is_probe and takeover.probe_owner == "w2"
async def test_release_probe_hands_back_and_idempotent(self, gate_factory, clock):
gate = gate_factory(_CFG)
await _open_gate(gate)
clock.advance(_CFG.cooldown_s + 1)
probe = await gate.try_enter("s1", "w1")
update = await gate.release_probe(probe)
assert update.applied
# 幂等: 第二次释放不再生效
assert not (await gate.release_probe(probe)).applied
# 源保持可接管状态: 下一 caller 拿到探针
nxt = await gate.try_enter("s1", "w2")
assert nxt.allowed and nxt.is_probe
class TestEpochFencing:
async def test_stale_epoch_write_rejected(self, gate_factory, clock):
"""entry 取得后世代已推进(他人触发开路)→ 迟到写回被 fencing 拒绝。"""
gate = gate_factory(_CFG)
stale_entry = await gate.try_enter("s1", "slow-worker")
await _open_gate(gate) # 他人连续失败 → 开路,epoch 推进
late = await gate.record_success(stale_entry)
assert not late.applied
# 门仍是 OPEN,未被迟到的成功污染
assert not (await gate.try_enter("s1", "w")).allowed
async def test_stale_probe_owner_rejected(self, gate_factory, clock):
gate = gate_factory(_CFG)
await _open_gate(gate)
clock.advance(_CFG.cooldown_s + 1)
stale_probe = await gate.try_enter("s1", "dead-worker")
clock.advance(_CFG.probe_ttl_s + 1)
takeover = await gate.try_enter("s1", "w2")
assert takeover.is_probe
# 死亡探针的迟到写回被拒(owner 已易主)
assert not (await gate.record_success(stale_probe)).applied
class TestRetryAfter:
async def test_retry_after_semantics(self, gate_factory, clock):
gate = gate_factory(_CFG)
assert await gate.retry_after_s(("s1",)) == 0.0 # 健康 → 0
await _open_gate(gate)
wait = await gate.retry_after_s(("s1",))
assert 0 < wait <= _CFG.cooldown_s
clock.advance(_CFG.cooldown_s + 1)
assert await gate.retry_after_s(("s1",)) == 0.0 # 冷却到期 → 0
with pytest.raises(ValueError):
await gate.retry_after_s(())
async def test_retry_after_takes_min_across_sources(self, gate_factory, clock):
gate = gate_factory(_CFG)
await _open_gate(gate, "s1") # s1 开路;s2 健康
assert await gate.retry_after_s(("s1", "s2")) == 0.0
+103
View File
@@ -0,0 +1,103 @@
"""限流后端契约测试(CHS tests/contracts_limiter.py 5 项 + M1 设计 §4.2 补强)。
任何 RateLimiter 后端都必须逐条通过;M2 的 Redis 实现复用本套件。
"""
from polygateway.types import GlobalLimits
from tests.contracts.conftest import make_source
_NO_GLOBAL = GlobalLimits(max_concurrency=0, rpm=0, tpm=0)
class TestConcurrencyGate:
async def test_concurrency_caps_and_zero_side_effect(self, limiter_factory):
src = make_source(max_concurrency=2)
limiter = limiter_factory([src], _NO_GLOBAL)
p1 = await limiter.try_acquire("s1", 0)
p2 = await limiter.try_acquire("s1", 0)
assert p1 is not None and p2 is not None
# 满员 → None,且失败的 acquire 零副作用
assert await limiter.try_acquire("s1", 0) is None
stats = await limiter.source_stats("s1")
assert stats.inflight == 2
# 释放一个后又能进
await p1.release()
assert (await limiter.source_stats("s1")).inflight == 1
p3 = await limiter.try_acquire("s1", 0)
assert p3 is not None
await p2.release()
await p3.release()
assert (await limiter.source_stats("s1")).inflight == 0
async def test_release_idempotent(self, limiter_factory):
limiter = limiter_factory([make_source(max_concurrency=1)], _NO_GLOBAL)
permit = await limiter.try_acquire("s1", 0)
await permit.release()
await permit.release()
assert (await limiter.source_stats("s1")).inflight == 0
async def test_global_concurrency_across_sources(self, limiter_factory):
sources = [make_source("s1"), make_source("s2")]
limiter = limiter_factory(sources, GlobalLimits(max_concurrency=2, rpm=0, tpm=0))
assert await limiter.try_acquire("s1", 0) is not None
assert await limiter.try_acquire("s2", 0) is not None
assert await limiter.try_acquire("s1", 0) is None # 全局闸挡住第三个
async def test_lease_expiry_reclaims_slot(self, limiter_factory, clock):
"""permit 持有者死亡(未 release)→ 租约过期后并发槽自动回收。"""
limiter = limiter_factory([make_source(max_concurrency=1)], _NO_GLOBAL, lease_ttl_s=30.0)
_leaked = await limiter.try_acquire("s1", 0)
assert await limiter.try_acquire("s1", 0) is None
clock.advance(31.0)
assert await limiter.try_acquire("s1", 0) is not None
class TestRpmGate:
async def test_rpm_not_refunded_by_release(self, limiter_factory):
src = make_source(rpm=3)
limiter = limiter_factory([src], _NO_GLOBAL)
for _ in range(3):
permit = await limiter.try_acquire("s1", 0)
assert permit is not None
await permit.release() # 释放并发,但 RPM 计数不归还
assert await limiter.try_acquire("s1", 0) is None
assert (await limiter.source_stats("s1")).rpm_used == 3
class TestTpmGate:
async def test_prededuct_and_settle_refund(self, limiter_factory):
src = make_source(tpm=1000, est_tokens=400)
limiter = limiter_factory([src], _NO_GLOBAL)
p1 = await limiter.try_acquire("s1", 400)
p2 = await limiter.try_acquire("s1", 400)
assert p1 is not None and p2 is not None
assert await limiter.try_acquire("s1", 400) is None # 预扣用满
# 实际 0 tokens → 全额退款
await p1.settle(0)
await p1.release()
assert (await limiter.source_stats("s1")).tpm_used == 400
# settle 幂等: 第二次调用无副作用
await p1.settle(0)
assert (await limiter.source_stats("s1")).tpm_used == 400
# 多退少补: 实际超预扣则补记
await p2.settle(600)
await p2.release()
assert (await limiter.source_stats("s1")).tpm_used == 600
async def test_failed_acquire_leaves_no_tpm_trace(self, limiter_factory):
src = make_source(tpm=500, est_tokens=400)
limiter = limiter_factory([src], _NO_GLOBAL)
p1 = await limiter.try_acquire("s1", 400)
assert p1 is not None
assert await limiter.try_acquire("s1", 400) is None
assert (await limiter.source_stats("s1")).tpm_used == 400 # 失败尝试零痕迹
class TestProgress:
async def test_progress_marks_fresh(self, limiter_factory, clock):
limiter = limiter_factory([make_source()], _NO_GLOBAL)
assert await limiter.progress_age_s() == float("inf") # 从未出餐
await limiter.mark_progress()
assert await limiter.progress_age_s() < 5.0
clock.advance(42.0)
assert 41.0 < await limiter.progress_age_s() < 43.0