breaker.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  1. # -*- coding: utf-8 -*-
  2. """
  3. Circuit-Breaker-Implementierung.
  4. Schützt Services vor kaskadierenden Fehlern durch
  5. automatisches Unterbrechen fehlgeschlagener Verbindungen.
  6. """
  7. from __future__ import annotations
  8. import asyncio
  9. import functools
  10. import threading
  11. import time
  12. from dataclasses import dataclass, field
  13. from datetime import datetime, timedelta
  14. from typing import Any, Callable, ParamSpec, Type, TypeVar
  15. from trixy_core.service.circuit_breaker.states import (
  16. CircuitEvent,
  17. CircuitState,
  18. CircuitStateMachine,
  19. StateTransition,
  20. )
  21. P = ParamSpec("P")
  22. R = TypeVar("R")
  23. class CircuitBreakerError(Exception):
  24. """
  25. Basis-Exception für Circuit-Breaker-Fehler.
  26. """
  27. def __init__(
  28. self,
  29. message: str,
  30. breaker_name: str = "",
  31. state: CircuitState | None = None,
  32. ) -> None:
  33. """
  34. Initialisiert den Fehler.
  35. Args:
  36. message: Fehlermeldung.
  37. breaker_name: Name des Circuit-Breakers.
  38. state: Aktueller Zustand.
  39. """
  40. super().__init__(message)
  41. self.breaker_name = breaker_name
  42. self.state = state
  43. class CircuitOpenError(CircuitBreakerError):
  44. """
  45. Exception wenn der Circuit offen ist.
  46. Wird geworfen wenn ein Request abgelehnt wird,
  47. weil der Circuit-Breaker offen ist.
  48. """
  49. def __init__(
  50. self,
  51. breaker_name: str,
  52. retry_after: float = 0.0,
  53. ) -> None:
  54. """
  55. Initialisiert den Fehler.
  56. Args:
  57. breaker_name: Name des Circuit-Breakers.
  58. retry_after: Zeit in Sekunden bis zum nächsten Versuch.
  59. """
  60. super().__init__(
  61. f"Circuit '{breaker_name}' ist offen",
  62. breaker_name=breaker_name,
  63. state=CircuitState.OPEN,
  64. )
  65. self.retry_after = retry_after
  66. @dataclass
  67. class CircuitBreakerConfig:
  68. """
  69. Konfiguration für einen Circuit-Breaker.
  70. """
  71. failure_threshold: int = 5
  72. """Anzahl Fehler bevor Circuit öffnet."""
  73. success_threshold: int = 3
  74. """Anzahl Erfolge in HALF_OPEN bevor Circuit schließt."""
  75. timeout: float = 30.0
  76. """Zeit in Sekunden bevor OPEN → HALF_OPEN."""
  77. half_open_max_calls: int = 3
  78. """Maximale Requests in HALF_OPEN."""
  79. failure_rate_threshold: float = 0.5
  80. """Fehlerrate (0.0-1.0) bevor Circuit öffnet."""
  81. min_calls_before_rate_check: int = 10
  82. """Mindest-Calls bevor Fehlerrate geprüft wird."""
  83. exceptions: tuple[Type[Exception], ...] = (Exception,)
  84. """Exceptions die als Fehler gezählt werden."""
  85. exclude_exceptions: tuple[Type[Exception], ...] = ()
  86. """Exceptions die nicht als Fehler gezählt werden."""
  87. on_open: Callable[[str], None] | None = None
  88. """Callback wenn Circuit öffnet."""
  89. on_close: Callable[[str], None] | None = None
  90. """Callback wenn Circuit schließt."""
  91. on_half_open: Callable[[str], None] | None = None
  92. """Callback wenn Circuit halb-offen wird."""
  93. @dataclass
  94. class CircuitBreakerStats:
  95. """
  96. Statistiken für einen Circuit-Breaker.
  97. """
  98. total_calls: int = 0
  99. """Gesamtzahl der Aufrufe."""
  100. successful_calls: int = 0
  101. """Erfolgreiche Aufrufe."""
  102. failed_calls: int = 0
  103. """Fehlgeschlagene Aufrufe."""
  104. rejected_calls: int = 0
  105. """Abgelehnte Aufrufe (Circuit offen)."""
  106. times_opened: int = 0
  107. """Wie oft der Circuit geöffnet wurde."""
  108. last_failure: datetime | None = None
  109. """Zeitpunkt des letzten Fehlers."""
  110. last_success: datetime | None = None
  111. """Zeitpunkt des letzten Erfolgs."""
  112. consecutive_failures: int = 0
  113. """Aktuelle Anzahl aufeinanderfolgender Fehler."""
  114. consecutive_successes: int = 0
  115. """Aktuelle Anzahl aufeinanderfolgender Erfolge."""
  116. @property
  117. def failure_rate(self) -> float:
  118. """Aktuelle Fehlerrate."""
  119. total = self.successful_calls + self.failed_calls
  120. if total == 0:
  121. return 0.0
  122. return self.failed_calls / total
  123. def record_success(self) -> None:
  124. """Zeichnet einen Erfolg auf."""
  125. self.total_calls += 1
  126. self.successful_calls += 1
  127. self.last_success = datetime.now()
  128. self.consecutive_successes += 1
  129. self.consecutive_failures = 0
  130. def record_failure(self) -> None:
  131. """Zeichnet einen Fehler auf."""
  132. self.total_calls += 1
  133. self.failed_calls += 1
  134. self.last_failure = datetime.now()
  135. self.consecutive_failures += 1
  136. self.consecutive_successes = 0
  137. def record_rejection(self) -> None:
  138. """Zeichnet eine Ablehnung auf."""
  139. self.rejected_calls += 1
  140. def reset(self) -> None:
  141. """Setzt die Statistiken zurück."""
  142. self.total_calls = 0
  143. self.successful_calls = 0
  144. self.failed_calls = 0
  145. self.rejected_calls = 0
  146. self.consecutive_failures = 0
  147. self.consecutive_successes = 0
  148. def to_dict(self) -> dict[str, Any]:
  149. """Konvertiert in ein Dictionary."""
  150. return {
  151. "total_calls": self.total_calls,
  152. "successful_calls": self.successful_calls,
  153. "failed_calls": self.failed_calls,
  154. "rejected_calls": self.rejected_calls,
  155. "failure_rate": self.failure_rate,
  156. "times_opened": self.times_opened,
  157. "consecutive_failures": self.consecutive_failures,
  158. "consecutive_successes": self.consecutive_successes,
  159. }
  160. class CircuitBreaker:
  161. """
  162. Circuit-Breaker für Fault Tolerance.
  163. Schützt vor kaskadierenden Fehlern durch automatisches
  164. Unterbrechen bei zu vielen Fehlern.
  165. Example:
  166. breaker = CircuitBreaker("database")
  167. try:
  168. with breaker:
  169. result = database.query("SELECT * FROM users")
  170. except CircuitOpenError:
  171. # Circuit ist offen, verwende Fallback
  172. result = cache.get("users")
  173. # Oder als Decorator
  174. @breaker
  175. def database_query():
  176. return database.query("SELECT * FROM users")
  177. """
  178. def __init__(
  179. self,
  180. name: str,
  181. config: CircuitBreakerConfig | None = None,
  182. ) -> None:
  183. """
  184. Initialisiert den Circuit-Breaker.
  185. Args:
  186. name: Name des Circuit-Breakers.
  187. config: Konfiguration.
  188. """
  189. self._name = name
  190. self._config = config or CircuitBreakerConfig()
  191. self._state_machine = CircuitStateMachine()
  192. self._stats = CircuitBreakerStats()
  193. self._lock = threading.Lock()
  194. self._opened_at: datetime | None = None
  195. self._half_open_calls = 0
  196. @property
  197. def name(self) -> str:
  198. """Name des Circuit-Breakers."""
  199. return self._name
  200. @property
  201. def state(self) -> CircuitState:
  202. """Aktueller Zustand."""
  203. return self._state_machine.state
  204. @property
  205. def stats(self) -> CircuitBreakerStats:
  206. """Statistiken."""
  207. return self._stats
  208. @property
  209. def config(self) -> CircuitBreakerConfig:
  210. """Konfiguration."""
  211. return self._config
  212. @property
  213. def is_closed(self) -> bool:
  214. """Prüft, ob der Circuit geschlossen ist."""
  215. return self.state == CircuitState.CLOSED
  216. @property
  217. def is_open(self) -> bool:
  218. """Prüft, ob der Circuit offen ist."""
  219. return self.state == CircuitState.OPEN
  220. @property
  221. def is_half_open(self) -> bool:
  222. """Prüft, ob der Circuit halb-offen ist."""
  223. return self.state == CircuitState.HALF_OPEN
  224. def _should_open(self) -> bool:
  225. """Prüft, ob der Circuit öffnen sollte."""
  226. # Failure-Threshold erreicht?
  227. if self._stats.consecutive_failures >= self._config.failure_threshold:
  228. return True
  229. # Failure-Rate zu hoch?
  230. if (
  231. self._stats.total_calls >= self._config.min_calls_before_rate_check
  232. and self._stats.failure_rate >= self._config.failure_rate_threshold
  233. ):
  234. return True
  235. return False
  236. def _should_close(self) -> bool:
  237. """Prüft, ob der Circuit schließen sollte (in HALF_OPEN)."""
  238. return self._stats.consecutive_successes >= self._config.success_threshold
  239. def _check_timeout(self) -> bool:
  240. """Prüft, ob der Open-Timeout abgelaufen ist."""
  241. if self._opened_at is None:
  242. return False
  243. elapsed = (datetime.now() - self._opened_at).total_seconds()
  244. return elapsed >= self._config.timeout
  245. def _transition_to(self, state: CircuitState, reason: str = "") -> None:
  246. """Führt einen Zustandsübergang durch."""
  247. old_state = self.state
  248. if state == CircuitState.OPEN:
  249. self._state_machine.force_state(CircuitState.OPEN, reason)
  250. self._opened_at = datetime.now()
  251. self._stats.times_opened += 1
  252. if self._config.on_open:
  253. self._config.on_open(self._name)
  254. elif state == CircuitState.HALF_OPEN:
  255. self._state_machine.force_state(CircuitState.HALF_OPEN, reason)
  256. self._half_open_calls = 0
  257. if self._config.on_half_open:
  258. self._config.on_half_open(self._name)
  259. elif state == CircuitState.CLOSED:
  260. self._state_machine.force_state(CircuitState.CLOSED, reason)
  261. self._opened_at = None
  262. self._half_open_calls = 0
  263. self._stats.consecutive_failures = 0
  264. if self._config.on_close:
  265. self._config.on_close(self._name)
  266. def allow_request(self) -> bool:
  267. """
  268. Prüft, ob ein Request erlaubt ist.
  269. Returns:
  270. True wenn erlaubt.
  271. """
  272. with self._lock:
  273. if self.state == CircuitState.CLOSED:
  274. return True
  275. if self.state == CircuitState.OPEN:
  276. # Timeout prüfen
  277. if self._check_timeout():
  278. self._transition_to(CircuitState.HALF_OPEN, "Timeout")
  279. return True
  280. return False
  281. if self.state == CircuitState.HALF_OPEN:
  282. if self._half_open_calls < self._config.half_open_max_calls:
  283. self._half_open_calls += 1
  284. return True
  285. return False
  286. return False
  287. def record_success(self) -> None:
  288. """Zeichnet einen Erfolg auf."""
  289. with self._lock:
  290. self._stats.record_success()
  291. if self.state == CircuitState.HALF_OPEN:
  292. if self._should_close():
  293. self._transition_to(CircuitState.CLOSED, "Erfolge erreicht")
  294. def record_failure(self, exception: Exception | None = None) -> None:
  295. """
  296. Zeichnet einen Fehler auf.
  297. Args:
  298. exception: Die aufgetretene Exception.
  299. """
  300. with self._lock:
  301. # Prüfen ob Exception als Fehler zählt
  302. if exception:
  303. if isinstance(exception, self._config.exclude_exceptions):
  304. return
  305. if not isinstance(exception, self._config.exceptions):
  306. return
  307. self._stats.record_failure()
  308. if self.state == CircuitState.CLOSED:
  309. if self._should_open():
  310. self._transition_to(CircuitState.OPEN, "Fehler-Threshold")
  311. elif self.state == CircuitState.HALF_OPEN:
  312. self._transition_to(CircuitState.OPEN, "Fehler in Half-Open")
  313. def __enter__(self) -> "CircuitBreaker":
  314. """Context-Manager-Eintritt."""
  315. if not self.allow_request():
  316. retry_after = 0.0
  317. if self._opened_at:
  318. elapsed = (datetime.now() - self._opened_at).total_seconds()
  319. retry_after = max(0, self._config.timeout - elapsed)
  320. self._stats.record_rejection()
  321. raise CircuitOpenError(self._name, retry_after)
  322. return self
  323. def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
  324. """Context-Manager-Austritt."""
  325. if exc_type is None:
  326. self.record_success()
  327. else:
  328. self.record_failure(exc_val)
  329. return False # Exception nicht unterdrücken
  330. def __call__(self, func: Callable[P, R]) -> Callable[P, R]:
  331. """Decorator-Verwendung."""
  332. @functools.wraps(func)
  333. def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
  334. with self:
  335. return func(*args, **kwargs)
  336. return wrapper
  337. def force_open(self) -> None:
  338. """Erzwingt das Öffnen des Circuits."""
  339. with self._lock:
  340. self._transition_to(CircuitState.OPEN, "Manuell")
  341. def force_close(self) -> None:
  342. """Erzwingt das Schließen des Circuits."""
  343. with self._lock:
  344. self._transition_to(CircuitState.CLOSED, "Manuell")
  345. def reset(self) -> None:
  346. """Setzt den Circuit-Breaker zurück."""
  347. with self._lock:
  348. self._transition_to(CircuitState.CLOSED, "Reset")
  349. self._stats.reset()
  350. self._state_machine.reset()
  351. def circuit_breaker(
  352. name: str | None = None,
  353. failure_threshold: int = 5,
  354. success_threshold: int = 3,
  355. timeout: float = 30.0,
  356. exceptions: tuple[Type[Exception], ...] = (Exception,),
  357. ) -> Callable[[Callable[P, R]], Callable[P, R]]:
  358. """
  359. Decorator für Circuit-Breaker.
  360. Args:
  361. name: Name des Circuit-Breakers (Default: Funktionsname).
  362. failure_threshold: Anzahl Fehler vor Öffnen.
  363. success_threshold: Anzahl Erfolge vor Schließen.
  364. timeout: Timeout in Sekunden.
  365. exceptions: Exceptions die als Fehler zählen.
  366. Returns:
  367. Decorator-Funktion.
  368. Example:
  369. @circuit_breaker(failure_threshold=3)
  370. def external_api_call():
  371. return requests.get(url).json()
  372. """
  373. config = CircuitBreakerConfig(
  374. failure_threshold=failure_threshold,
  375. success_threshold=success_threshold,
  376. timeout=timeout,
  377. exceptions=exceptions,
  378. )
  379. def decorator(func: Callable[P, R]) -> Callable[P, R]:
  380. breaker_name = name or func.__qualname__
  381. breaker = CircuitBreaker(breaker_name, config)
  382. @functools.wraps(func)
  383. def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
  384. with breaker:
  385. return func(*args, **kwargs)
  386. # Zugriff auf Breaker
  387. wrapper.circuit_breaker = breaker # type: ignore
  388. return wrapper
  389. return decorator
  390. # Registry für globale Circuit-Breaker
  391. _breakers: dict[str, CircuitBreaker] = {}
  392. def get_circuit_breaker(
  393. name: str,
  394. config: CircuitBreakerConfig | None = None,
  395. ) -> CircuitBreaker:
  396. """
  397. Holt oder erstellt einen Circuit-Breaker.
  398. Args:
  399. name: Name des Circuit-Breakers.
  400. config: Optionale Konfiguration.
  401. Returns:
  402. CircuitBreaker-Instanz.
  403. """
  404. if name not in _breakers:
  405. _breakers[name] = CircuitBreaker(name, config)
  406. return _breakers[name]
  407. def get_all_circuit_breakers() -> dict[str, CircuitBreaker]:
  408. """Gibt alle registrierten Circuit-Breaker zurück."""
  409. return dict(_breakers)