retry.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. # -*- coding: utf-8 -*-
  2. """
  3. Retry-Decorators mit exponential Backoff und Jitter.
  4. Bietet robuste Retry-Logik für fehleranfällige Operationen
  5. wie Netzwerk-Requests oder Datenbank-Zugriffe.
  6. """
  7. from __future__ import annotations
  8. import asyncio
  9. import functools
  10. import logging
  11. import random
  12. import time
  13. from dataclasses import dataclass, field
  14. from datetime import datetime
  15. from typing import Any, Callable, ParamSpec, Sequence, Type, TypeVar
  16. P = ParamSpec("P")
  17. R = TypeVar("R")
  18. @dataclass
  19. class RetryConfig:
  20. """
  21. Konfiguration für Retry-Verhalten.
  22. Definiert alle Parameter für die Retry-Logik inklusive
  23. Backoff-Strategie und Ausnahmebehandlung.
  24. """
  25. max_attempts: int = 3
  26. """Maximale Anzahl von Versuchen (inklusive dem ersten)."""
  27. base_delay: float = 1.0
  28. """Basis-Verzögerung in Sekunden."""
  29. max_delay: float = 60.0
  30. """Maximale Verzögerung in Sekunden."""
  31. exponential_base: float = 2.0
  32. """Basis für exponentielles Backoff (2.0 = Verdoppelung)."""
  33. jitter: float = 0.1
  34. """Jitter-Faktor (0.0 - 1.0). Fügt Zufälligkeit hinzu."""
  35. retry_on: tuple[Type[BaseException], ...] = (Exception,)
  36. """Ausnahmen, bei denen ein Retry erfolgen soll."""
  37. retry_if: Callable[[BaseException], bool] | None = None
  38. """Optionale Funktion zur Entscheidung, ob Retry erfolgen soll."""
  39. on_retry: Callable[[int, BaseException, float], None] | None = None
  40. """Callback bei jedem Retry: (attempt, exception, delay)."""
  41. on_failure: Callable[[BaseException, int], None] | None = None
  42. """Callback bei endgültigem Fehlschlag: (exception, total_attempts)."""
  43. def calculate_delay(self, attempt: int) -> float:
  44. """
  45. Berechnet die Verzögerung für einen bestimmten Versuch.
  46. Args:
  47. attempt: Aktuelle Versuchsnummer (1-basiert).
  48. Returns:
  49. Verzögerung in Sekunden.
  50. """
  51. # Exponentielles Backoff
  52. delay = self.base_delay * (self.exponential_base ** (attempt - 1))
  53. # Maximum begrenzen
  54. delay = min(delay, self.max_delay)
  55. # Jitter hinzufügen
  56. if self.jitter > 0:
  57. jitter_range = delay * self.jitter
  58. delay += random.uniform(-jitter_range, jitter_range)
  59. return max(0, delay)
  60. def should_retry(self, exception: BaseException, attempt: int) -> bool:
  61. """
  62. Prüft, ob ein weiterer Versuch unternommen werden sollte.
  63. Args:
  64. exception: Die aufgetretene Ausnahme.
  65. attempt: Aktuelle Versuchsnummer.
  66. Returns:
  67. True wenn Retry erfolgen soll.
  68. """
  69. # Maximale Versuche erreicht?
  70. if attempt >= self.max_attempts:
  71. return False
  72. # Ausnahme-Typ prüfen
  73. if not isinstance(exception, self.retry_on):
  74. return False
  75. # Custom-Prüfung
  76. if self.retry_if and not self.retry_if(exception):
  77. return False
  78. return True
  79. @dataclass
  80. class RetryStats:
  81. """
  82. Statistiken für Retry-Operationen.
  83. Sammelt Informationen über Erfolge, Fehlschläge und Retries.
  84. """
  85. function_name: str
  86. total_calls: int = 0
  87. successful_calls: int = 0
  88. failed_calls: int = 0
  89. total_retries: int = 0
  90. total_retry_time: float = 0.0
  91. last_failure: datetime | None = None
  92. last_failure_reason: str | None = None
  93. @property
  94. def success_rate(self) -> float:
  95. """Erfolgsrate in Prozent."""
  96. if self.total_calls == 0:
  97. return 100.0
  98. return (self.successful_calls / self.total_calls) * 100
  99. @property
  100. def avg_retries_per_call(self) -> float:
  101. """Durchschnittliche Retries pro Aufruf."""
  102. if self.total_calls == 0:
  103. return 0.0
  104. return self.total_retries / self.total_calls
  105. def to_dict(self) -> dict[str, Any]:
  106. """Konvertiert die Statistiken in ein Dictionary."""
  107. return {
  108. "function": self.function_name,
  109. "total_calls": self.total_calls,
  110. "successful_calls": self.successful_calls,
  111. "failed_calls": self.failed_calls,
  112. "success_rate": self.success_rate,
  113. "total_retries": self.total_retries,
  114. "avg_retries_per_call": self.avg_retries_per_call,
  115. "total_retry_time_seconds": self.total_retry_time,
  116. "last_failure": (
  117. self.last_failure.isoformat() if self.last_failure else None
  118. ),
  119. "last_failure_reason": self.last_failure_reason,
  120. }
  121. # Globaler Speicher für Retry-Statistiken
  122. _retry_stats: dict[str, RetryStats] = {}
  123. def get_retry_stats(function_name: str | None = None) -> dict[str, RetryStats]:
  124. """
  125. Gibt Retry-Statistiken zurück.
  126. Args:
  127. function_name: Optional spezifischer Funktionsname.
  128. Returns:
  129. Dictionary mit Statistiken.
  130. """
  131. if function_name:
  132. if function_name in _retry_stats:
  133. return {function_name: _retry_stats[function_name]}
  134. return {}
  135. return dict(_retry_stats)
  136. def retry(
  137. max_attempts: int = 3,
  138. base_delay: float = 1.0,
  139. max_delay: float = 60.0,
  140. exponential_base: float = 2.0,
  141. jitter: float = 0.1,
  142. retry_on: tuple[Type[BaseException], ...] | Type[BaseException] = (Exception,),
  143. retry_if: Callable[[BaseException], bool] | None = None,
  144. on_retry: Callable[[int, BaseException, float], None] | None = None,
  145. on_failure: Callable[[BaseException, int], None] | None = None,
  146. config: RetryConfig | None = None,
  147. ) -> Callable[[Callable[P, R]], Callable[P, R]]:
  148. """
  149. Decorator für automatische Retry-Logik mit exponential Backoff.
  150. Args:
  151. max_attempts: Maximale Anzahl von Versuchen.
  152. base_delay: Basis-Verzögerung in Sekunden.
  153. max_delay: Maximale Verzögerung in Sekunden.
  154. exponential_base: Basis für exponentielles Backoff.
  155. jitter: Jitter-Faktor für Zufälligkeit.
  156. retry_on: Ausnahme(n), bei denen Retry erfolgen soll.
  157. retry_if: Optionale Funktion für Retry-Entscheidung.
  158. on_retry: Callback bei jedem Retry.
  159. on_failure: Callback bei endgültigem Fehlschlag.
  160. config: Optional vorkonfiguriertes RetryConfig.
  161. Returns:
  162. Decorator-Funktion.
  163. Example:
  164. @retry(max_attempts=3, retry_on=ConnectionError)
  165. def fetch_data():
  166. return requests.get(url).json()
  167. @retry(
  168. max_attempts=5,
  169. retry_if=lambda e: "temporary" in str(e).lower()
  170. )
  171. def unstable_operation():
  172. pass
  173. """
  174. # Config erstellen oder verwenden
  175. if config:
  176. cfg = config
  177. else:
  178. if isinstance(retry_on, type):
  179. retry_on = (retry_on,)
  180. cfg = RetryConfig(
  181. max_attempts=max_attempts,
  182. base_delay=base_delay,
  183. max_delay=max_delay,
  184. exponential_base=exponential_base,
  185. jitter=jitter,
  186. retry_on=retry_on,
  187. retry_if=retry_if,
  188. on_retry=on_retry,
  189. on_failure=on_failure,
  190. )
  191. def decorator(func: Callable[P, R]) -> Callable[P, R]:
  192. # Statistik-Objekt erstellen
  193. stats_name = func.__qualname__
  194. if stats_name not in _retry_stats:
  195. _retry_stats[stats_name] = RetryStats(function_name=stats_name)
  196. stats = _retry_stats[stats_name]
  197. @functools.wraps(func)
  198. def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
  199. stats.total_calls += 1
  200. last_exception: BaseException | None = None
  201. retry_count = 0
  202. total_delay = 0.0
  203. for attempt in range(1, cfg.max_attempts + 1):
  204. try:
  205. result = func(*args, **kwargs)
  206. stats.successful_calls += 1
  207. stats.total_retries += retry_count
  208. stats.total_retry_time += total_delay
  209. return result
  210. except BaseException as e:
  211. last_exception = e
  212. if not cfg.should_retry(e, attempt):
  213. break
  214. # Verzögerung berechnen und warten
  215. delay = cfg.calculate_delay(attempt)
  216. total_delay += delay
  217. # Callback aufrufen
  218. if cfg.on_retry:
  219. cfg.on_retry(attempt, e, delay)
  220. retry_count += 1
  221. time.sleep(delay)
  222. # Endgültiger Fehlschlag
  223. stats.failed_calls += 1
  224. stats.total_retries += retry_count
  225. stats.total_retry_time += total_delay
  226. stats.last_failure = datetime.now()
  227. stats.last_failure_reason = str(last_exception)
  228. if cfg.on_failure and last_exception:
  229. cfg.on_failure(last_exception, cfg.max_attempts)
  230. # Letzte Exception erneut werfen
  231. if last_exception:
  232. raise last_exception
  233. raise RuntimeError("Retry fehlgeschlagen ohne Exception")
  234. # Statistik-Zugriff über die Funktion
  235. wrapper.retry_stats = stats # type: ignore
  236. return wrapper
  237. return decorator
  238. def async_retry(
  239. max_attempts: int = 3,
  240. base_delay: float = 1.0,
  241. max_delay: float = 60.0,
  242. exponential_base: float = 2.0,
  243. jitter: float = 0.1,
  244. retry_on: tuple[Type[BaseException], ...] | Type[BaseException] = (Exception,),
  245. retry_if: Callable[[BaseException], bool] | None = None,
  246. on_retry: Callable[[int, BaseException, float], None] | None = None,
  247. on_failure: Callable[[BaseException, int], None] | None = None,
  248. config: RetryConfig | None = None,
  249. ) -> Callable[[Callable[P, R]], Callable[P, R]]:
  250. """
  251. Async-Decorator für Retry-Logik.
  252. Wie @retry, aber für async Funktionen.
  253. Args:
  254. max_attempts: Maximale Anzahl von Versuchen.
  255. base_delay: Basis-Verzögerung in Sekunden.
  256. max_delay: Maximale Verzögerung in Sekunden.
  257. exponential_base: Basis für exponentielles Backoff.
  258. jitter: Jitter-Faktor.
  259. retry_on: Ausnahmen für Retry.
  260. retry_if: Optionale Prüffunktion.
  261. on_retry: Callback bei Retry.
  262. on_failure: Callback bei Fehlschlag.
  263. config: Optionales RetryConfig.
  264. Returns:
  265. Decorator-Funktion.
  266. Example:
  267. @async_retry(max_attempts=3)
  268. async def fetch_async():
  269. async with aiohttp.ClientSession() as session:
  270. async with session.get(url) as response:
  271. return await response.json()
  272. """
  273. if config:
  274. cfg = config
  275. else:
  276. if isinstance(retry_on, type):
  277. retry_on = (retry_on,)
  278. cfg = RetryConfig(
  279. max_attempts=max_attempts,
  280. base_delay=base_delay,
  281. max_delay=max_delay,
  282. exponential_base=exponential_base,
  283. jitter=jitter,
  284. retry_on=retry_on,
  285. retry_if=retry_if,
  286. on_retry=on_retry,
  287. on_failure=on_failure,
  288. )
  289. def decorator(func: Callable[P, R]) -> Callable[P, R]:
  290. stats_name = func.__qualname__
  291. if stats_name not in _retry_stats:
  292. _retry_stats[stats_name] = RetryStats(function_name=stats_name)
  293. stats = _retry_stats[stats_name]
  294. @functools.wraps(func)
  295. async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
  296. stats.total_calls += 1
  297. last_exception: BaseException | None = None
  298. retry_count = 0
  299. total_delay = 0.0
  300. for attempt in range(1, cfg.max_attempts + 1):
  301. try:
  302. result = await func(*args, **kwargs)
  303. stats.successful_calls += 1
  304. stats.total_retries += retry_count
  305. stats.total_retry_time += total_delay
  306. return result
  307. except BaseException as e:
  308. last_exception = e
  309. if not cfg.should_retry(e, attempt):
  310. break
  311. delay = cfg.calculate_delay(attempt)
  312. total_delay += delay
  313. if cfg.on_retry:
  314. cfg.on_retry(attempt, e, delay)
  315. retry_count += 1
  316. await asyncio.sleep(delay)
  317. stats.failed_calls += 1
  318. stats.total_retries += retry_count
  319. stats.total_retry_time += total_delay
  320. stats.last_failure = datetime.now()
  321. stats.last_failure_reason = str(last_exception)
  322. if cfg.on_failure and last_exception:
  323. cfg.on_failure(last_exception, cfg.max_attempts)
  324. if last_exception:
  325. raise last_exception
  326. raise RuntimeError("Retry fehlgeschlagen ohne Exception")
  327. wrapper.retry_stats = stats # type: ignore
  328. return wrapper
  329. return decorator
  330. # Vordefinierte Retry-Konfigurationen
  331. NETWORK_RETRY = RetryConfig(
  332. max_attempts=3,
  333. base_delay=1.0,
  334. max_delay=30.0,
  335. exponential_base=2.0,
  336. jitter=0.2,
  337. retry_on=(ConnectionError, TimeoutError, OSError),
  338. )
  339. DATABASE_RETRY = RetryConfig(
  340. max_attempts=5,
  341. base_delay=0.5,
  342. max_delay=10.0,
  343. exponential_base=1.5,
  344. jitter=0.1,
  345. )
  346. AGGRESSIVE_RETRY = RetryConfig(
  347. max_attempts=10,
  348. base_delay=0.1,
  349. max_delay=5.0,
  350. exponential_base=1.5,
  351. jitter=0.3,
  352. )