Files
xiaobai-review/xiaobai-datahub/datahub/governance/circuit.py
T
3498dd7a4b feat(HEL-382): 搭建 datahub 底座和盘后正式数据链路
新增独立 xiaobai-datahub 服务(SQLite WAL、Tushare 盘后发布、/v1 契约和管理后台),不改现站页面与数据链路。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 12:05:26 +08:00

108 lines
3.7 KiB
Python

from __future__ import annotations
import threading
import time
from collections import deque
from dataclasses import dataclass
@dataclass
class CircuitState:
state: str = "closed" # closed | open | half_open
consec_failures: int = 0
opened_at: float | None = None
cooldown_until: float = 0.0
last_error: str = ""
last_ok_at: float | None = None
class CircuitBreaker:
"""Sliding-window breaker: 5 consecutive failures or >50% of 60s window → open."""
def __init__(
self,
failure_threshold: int = 5,
window_seconds: float = 60.0,
open_seconds: float = 120.0,
max_open_seconds: float = 600.0,
clock=time.monotonic,
) -> None:
self.failure_threshold = failure_threshold
self.window_seconds = window_seconds
self.open_seconds = open_seconds
self.max_open_seconds = max_open_seconds
self._clock = clock
self._lock = threading.Lock()
self._events: deque[tuple[float, bool]] = deque()
self.status = CircuitState()
self._open_stretch = open_seconds
def allow(self) -> bool:
with self._lock:
self._refresh_locked()
if self.status.state == "open":
return False
if self.status.state == "half_open":
# single probe in flight: caller must record success/failure
return True
return True
def record_success(self) -> CircuitState:
with self._lock:
now = self._clock()
self._events.append((now, True))
self.status.last_ok_at = now
self.status.consec_failures = 0
self.status.last_error = ""
self._open_stretch = self.open_seconds
self.status.state = "closed"
self.status.opened_at = None
self.status.cooldown_until = 0.0
return self._copy()
def record_failure(self, error: str = "") -> CircuitState:
with self._lock:
now = self._clock()
self._events.append((now, False))
self.status.consec_failures += 1
self.status.last_error = error
self._prune_locked(now)
failures = sum(1 for _, ok in self._events if not ok)
total = len(self._events)
rate = (failures / total) if total else 0.0
trip = self.status.consec_failures >= self.failure_threshold or (
total >= self.failure_threshold and rate > 0.5
)
if trip:
self.status.state = "open"
self.status.opened_at = now
self.status.cooldown_until = now + self._open_stretch
self._open_stretch = min(self.max_open_seconds, self._open_stretch * 2)
return self._copy()
def snapshot(self) -> CircuitState:
with self._lock:
self._refresh_locked()
return self._copy()
def _refresh_locked(self) -> None:
now = self._clock()
self._prune_locked(now)
if self.status.state == "open" and now >= self.status.cooldown_until:
self.status.state = "half_open"
def _prune_locked(self, now: float) -> None:
cutoff = now - self.window_seconds
while self._events and self._events[0][0] < cutoff:
self._events.popleft()
def _copy(self) -> CircuitState:
return CircuitState(
state=self.status.state,
consec_failures=self.status.consec_failures,
opened_at=self.status.opened_at,
cooldown_until=self.status.cooldown_until,
last_error=self.status.last_error,
last_ok_at=self.status.last_ok_at,
)