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, )