"""进程内熔断器:按 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] = {} self._half_open_inflight: dict[str, bool] = {} def is_open(self, source_name: str, now: float) -> bool: """判断指定源是否处于开路状态。 冷却截止时刻之前为开路;到期进入半开,**只放行一个探针**(其余仍被挡), 避免冷却到期瞬间惊群重连再次压垮上游。"检查+标记探针"在 asyncio 单线程内 同步执行,天然原子无竞态。 Args: source_name: 被熔断的源标识。 now: 当前时刻(秒级时间戳),由调用方注入。 Returns: True 表示开路(拒绝请求),False 表示关闭或半开放行探针。 """ until = self._open_until.get(source_name) if until is None: return False if now < until: return True # 冷却中,全挡 # 冷却到期:half-open,只放行一个探针 if self._half_open_inflight.get(source_name): return True # 已有探针在途,继续挡 self._half_open_inflight[source_name] = True return False 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 # 探针失败清在途标记,使下一轮 cooldown 到期后可再放行探针 self._half_open_inflight.pop(source_name, None) 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 self._half_open_inflight.pop(source_name, None) def record_success(self, source_name: str) -> None: """记录一次成功;清零失败计数与开路状态(关闭熔断器)。 Args: source_name: 成功的源标识。 """ self._fails.pop(source_name, None) self._open_until.pop(source_name, None) self._half_open_inflight.pop(source_name, None)