diff --git a/tenacity/retry.py b/tenacity/retry.py index df0cc4d6..d478c0a3 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -115,7 +115,12 @@ def _check(self, e: BaseException) -> bool: class retry_if_not_exception_type(retry_if_exception): - """Retries except an exception has been raised of one or more types.""" + """Retries except an exception has been raised of one or more types. + + Note: asyncio.CancelledError is never retried, regardless of exception_types, + as retrying cancellations defeats task cancellation semantics. + See: https://github.com/jd/tenacity/issues/529 + """ def __init__( self, @@ -126,6 +131,10 @@ def __init__( super().__init__(self._check) def _check(self, e: BaseException) -> bool: + # Fix #529: never retry CancelledError — task cancellation must propagate + import asyncio as _asyncio + if isinstance(e, _asyncio.CancelledError): + return False return not isinstance(e, self.exception_types) @@ -223,22 +232,22 @@ def __init__( message: str | None = None, match: None | str | re.Pattern[str] = None, ) -> None: - if message and match: + if message is not None and match is not None: raise TypeError( f"{self.__class__.__name__}() takes either 'message' or 'match', not both" ) - if not message and not match: + if message is None and match is None: raise TypeError( f"{self.__class__.__name__}() missing 1 required argument 'message' or 'match'" ) self.message = message - self.match = re.compile(match) if match else None + self.match = re.compile(match) if match is not None else None super().__init__(self._check) def _check(self, exception: BaseException) -> bool: - if self.message: + if self.message is not None: return self.message == str(exception) assert self.match is not None return bool(self.match.match(str(exception))) @@ -290,4 +299,4 @@ def __call__(self, retry_state: "RetryCallState") -> bool: def __rand__(self, other: "RetryBaseT") -> "retry_all": if isinstance(other, retry_all): return retry_all(*other.retries, *self.retries) - return retry_all(other, *self.retries) + return retry_all(other, *self.retries) \ No newline at end of file diff --git a/tenacity/wait.py b/tenacity/wait.py index c7a430eb..83f481f2 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -286,3 +286,129 @@ def __call__(self, retry_state: "RetryCallState") -> float: except OverflowError: result = self.max return max(max(0, self.min), min(result, self.max)) + + +# ── LaForge additions — rate-limit aware strategies ──────────────────────────── +import time as _time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from tenacity import RetryCallState + + +class wait_retry_after(wait_base): + """Lit le header HTTP Retry-After de la reponse 429, fallback exponentiel.""" + + def __init__(self, fallback_wait: float = 1.0) -> None: + self.fallback_wait = fallback_wait + + def __call__(self, retry_state: "RetryCallState") -> float: + """Lit le header HTTP Retry-After de la reponse 429, fallback exponentiel. + + Args: + retry_state: L'état de la tentative de réessai. + + Returns: + Le temps d'attente avant la prochaine tentative. + """ + if retry_state.outcome is not None: + exc = retry_state.outcome.exception() + # Header Retry-After dans la reponse HTTP + resp = getattr(exc, "response", None) + if resp is not None: + after = getattr(getattr(resp, "headers", {}), "get", lambda k: None)("Retry-After") + if after is not None: + try: + return float(after) + except (TypeError, ValueError): + pass + # Fallback exponentiel + return wait_exponential()(retry_state) + + def __repr__(self) -> str: + """Représentation de l'objet.""" + return f"wait_retry_after(fallback_wait={self.fallback_wait})" + + +class wait_rpm_budget(wait_base): + """Attend si le budget RPM par provider est depasse (>85%). + + Partage un compteur de classe entre toutes les instances du meme provider_key. + Utile pour plusieurs coroutines qui partagent le meme quota cloud. + """ + + _calls: "dict[str, list[float]]" = {} + + def __init__(self, provider_key: str = "default", rpm_limit: int = 30) -> None: + self.provider_key = provider_key + self.rpm_limit = rpm_limit + + def __call__(self, retry_state: "RetryCallState") -> float: + """Attend si le budget RPM par provider est depasse (>85%). + + Args: + retry_state: L'état de la tentative de réessai. + + Returns: + Le temps d'attente avant la prochaine tentative. + """ + now = _time.time() + bucket = self._calls.setdefault(self.provider_key, []) + # Purger les appels > 1 min + self._calls[self.provider_key] = [t for t in bucket if now - t < 60] + self._calls[self.provider_key].append(now) + + if len(self._calls[self.provider_key]) >= self.rpm_limit * 0.85: + # Attendre jusqu'a la prochaine fenetre d'1 minute + oldest = self._calls[self.provider_key][0] + sleep_until = oldest + 60.0 + return min(60.0, max(1.0, sleep_until - now)) + return 0.0 + + def __repr__(self) -> str: + """Représentation de l'objet.""" + return f"wait_rpm_budget(provider_key={self.provider_key}, rpm_limit={self.rpm_limit})" + + @classmethod + def reset(cls, provider_key: str = "default") -> None: + """Reinitialise le compteur RPM d'un provider. + + Args: + provider_key: La clé du fournisseur. + """ + cls._calls.pop(provider_key, None) + + +def retry_if_rate_limited() -> callable: + """Predicate tenacity : detecte les erreurs de rate limit cloud (Groq/Gemini/OpenRouter). + + Usage: + @retry(retry=retry_if_rate_limited(), wait=wait_rpm_budget("groq", rpm_limit=30)) + async def call_api(): ... + + Returns: + Une fonction qui prend une exception en argument et retourne True si l'exception est liée à une erreur de rate limit. + """ + def _is_rate_limited(exc: Exception) -> bool: + # litellm.RateLimitError (import lazy pour eviter dep obligatoire) + try: + import litellm as _ll + if isinstance(exc, _ll.RateLimitError): + return True + except ImportError: + pass + # httpx.HTTPStatusError 429 + try: + import httpx as _hx + if isinstance(exc, _hx.HTTPStatusError): + return exc.response.status_code == 429 + except ImportError: + pass + # asyncio.TimeoutError + if isinstance(exc, TimeoutError): + return True + # Fallback textuel + return any(kw in str(exc).lower() for kw in ("rate_limit", "429", "quota", "too many")) + + from tenacity import retry_if_exception + return retry_if_exception(_is_rate_limited)