feat(adapters): 实现 CircuitBreaker 内存级熔断器

注入 now 纯确定性,force_open 支持 401/403 直接熔断。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 22:39:57 -04:00
parent 9dae7dda98
commit 14bb60e918
2 changed files with 139 additions and 0 deletions
+70
View File
@@ -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)
+69
View File
@@ -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.0open_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.0now=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