diff --git a/adapters/breaker.py b/adapters/breaker.py new file mode 100644 index 0000000..1555142 --- /dev/null +++ b/adapters/breaker.py @@ -0,0 +1,70 @@ +"""进程内熔断器:按 source_name 记连续失败数与冷却截止时刻。 + +内存级单实例,now 由调用方注入以保证纯确定性可测试。达阈值后开路冷却, +冷却到期放行试探(half-open)。force_open 支持 401/403 一次即熔断。 +""" + +from __future__ import annotations + + +class CircuitBreaker: + """按 source_name 记失败数;达阈值后开路冷却,冷却到期放行试探(half-open)。 + + Args: + fail_threshold: 连续失败次数阈值,达到后触发开路。 + cooldown_s: 开路冷却时长(秒),冷却到期自动进入半开状态。 + """ + + def __init__(self, fail_threshold: int, cooldown_s: float) -> None: + self._fail_threshold = fail_threshold + self._cooldown_s = cooldown_s + self._fails: dict[str, int] = {} + self._open_until: dict[str, float] = {} + + def is_open(self, source_name: str, now: float) -> bool: + """判断指定源是否处于开路状态。 + + 冷却截止时刻之前为开路;到期返回 False(放行一个试探,即半开)。 + + Args: + source_name: 被熔断的源标识。 + now: 当前时刻(秒级时间戳),由调用方注入。 + + Returns: + True 表示开路(拒绝请求),False 表示关闭或半开(放行)。 + """ + until = self._open_until.get(source_name) + return until is not None and now < until + + def record_failure(self, source_name: str, now: float) -> None: + """记录一次失败;累计达阈值则开路至 now + cooldown。 + + Args: + source_name: 失败的源标识。 + now: 当前时刻(秒级时间戳)。 + """ + count = self._fails.get(source_name, 0) + 1 + self._fails[source_name] = count + if count >= self._fail_threshold: + self._open_until[source_name] = now + self._cooldown_s + + def force_open(self, source_name: str, now: float) -> None: + """强制开路(用于 401/403 等不可恢复错误),一次即熔断。 + + 将失败计数预设为阈值,使半开探针仅需一次失败即可重新熔断。 + + Args: + source_name: 需要强制熔断的源标识。 + now: 当前时刻(秒级时间戳)。 + """ + self._fails[source_name] = self._fail_threshold + self._open_until[source_name] = now + self._cooldown_s + + def record_success(self, source_name: str) -> None: + """记录一次成功;清零失败计数与开路状态(关闭熔断器)。 + + Args: + source_name: 成功的源标识。 + """ + self._fails.pop(source_name, None) + self._open_until.pop(source_name, None) diff --git a/tests/unit/test_breaker.py b/tests/unit/test_breaker.py new file mode 100644 index 0000000..348c2da --- /dev/null +++ b/tests/unit/test_breaker.py @@ -0,0 +1,69 @@ +"""CircuitBreaker 单元测试。 + +验证内存级熔断器的核心行为:阈值触发、冷却半开、成功重置、强制熔断。 +""" + +from __future__ import annotations + +import pytest + +from adapters.breaker import CircuitBreaker + + +@pytest.fixture() +def breaker() -> CircuitBreaker: + """创建 threshold=3, cooldown=10s 的熔断器。""" + return CircuitBreaker(fail_threshold=3, cooldown_s=10.0) + + +class TestCircuitBreaker: + """CircuitBreaker 行为验证。""" + + def test_closed_by_default(self, breaker: CircuitBreaker) -> None: + """初始状态为关闭(放行)。""" + assert breaker.is_open("llm", now=0.0) is False + + def test_opens_after_threshold_failures(self, breaker: CircuitBreaker) -> None: + """连续失败未达阈值不开路;达到阈值后开路。""" + # 2 次失败 < threshold=3,仍关闭 + breaker.record_failure("llm", now=1.0) + breaker.record_failure("llm", now=2.0) + assert breaker.is_open("llm", now=2.5) is False + + # 第 3 次失败 >= threshold=3,开路 + breaker.record_failure("llm", now=3.0) + assert breaker.is_open("llm", now=3.5) is True + + def test_half_open_after_cooldown(self, breaker: CircuitBreaker) -> None: + """冷却期过后自动半开(放行试探)。""" + for t in range(3): + breaker.record_failure("llm", now=float(t)) + # 开路期间 + assert breaker.is_open("llm", now=5.0) is True + # cooldown=10s,第 3 次失败 now=2.0,open_until=12.0 + # now=12.0 时冷却到期 → 半开 + assert breaker.is_open("llm", now=12.0) is False + + def test_success_resets(self, breaker: CircuitBreaker) -> None: + """record_success 重置计数并关闭熔断器。""" + for t in range(3): + breaker.record_failure("llm", now=float(t)) + assert breaker.is_open("llm", now=3.0) is True + + breaker.record_success("llm") + assert breaker.is_open("llm", now=3.0) is False + + def test_force_open_immediate(self, breaker: CircuitBreaker) -> None: + """force_open 无需积累失败即可直接熔断。""" + breaker.force_open("llm", now=5.0) + assert breaker.is_open("llm", now=5.5) is True + + def test_force_open_probe_failure_reopens(self, breaker: CircuitBreaker) -> None: + """半开探针失败后重新熔断(因 fails 已置为 threshold)。""" + breaker.force_open("llm", now=0.0) + # cooldown=10s → open_until=10.0,now=10.0 半开 + assert breaker.is_open("llm", now=10.0) is False + + # 半开探针失败:一次 record_failure 即达阈值(fails 已=3),重新熔断 + breaker.record_failure("llm", now=10.0) + assert breaker.is_open("llm", now=10.5) is True