新增独立 xiaobai-datahub 服务(SQLite WAL、Tushare 盘后发布、/v1 契约和管理后台),不改现站页面与数据链路。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
108 lines
3.7 KiB
Python
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,
|
|
)
|