Round-1 review on PR #893: - _drop_reason now descends through nested ExceptionGroups to the first leaf (was single-level), so a doubly-wrapped cause isn't mislabelled "other"; added a nested-group test. Commented why both the httpx and openai isinstance branches exist (raw Nextcloud-API errors vs SDK-wrapped variants). - Documented that generate() intentionally shares the broadened transient retry (RAG sampling path), with the worst-case latency note. - Added a docstring note to process_document on how the provider-level retry (5x) layers over the outer loop (3x in-process / 1x procrastinate). - Added test_embed_batch_retries_on_connection_error for the batch path. - Renamed test_retry_reraises_non_rate_limit_immediately -> test_retry_reraises_when_predicate_returns_false (it tests the predicate, not a specific status). SonarCloud: - S5708 (BLOCKER) on the helper's dynamic `except exception_type`: the type is constrained to BaseException/tuple by the signature; suppressed with a justified NOSONAR. - S7503 (async without await) in the embed-retry test: use AsyncMock side_effect instead of a hand-rolled async function. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
96 lines
3.6 KiB
Python
96 lines
3.6 KiB
Python
"""Shared transient-error retry helper for provider modules.
|
|
|
|
OpenAI and Mistral retry transient failures (429 rate limits, plus connection
|
|
drops / timeouts / 5xx for the embedding path) on the same exponential-backoff
|
|
curve; extracting the loop here keeps the two provider modules thin and lets
|
|
future providers (Bedrock throttling, etc.) reuse the same primitive. The
|
|
``should_retry`` predicate decides which caught exceptions are transient.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Awaitable, Callable
|
|
from functools import wraps
|
|
from typing import Any, TypeVar
|
|
|
|
import anyio
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MAX_RETRIES = 5
|
|
INITIAL_RETRY_DELAY = 2.0
|
|
MAX_RETRY_DELAY = 60.0
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
def retry_on_transient(
|
|
exception_type: type[BaseException] | tuple[type[BaseException], ...],
|
|
should_retry: Callable[[BaseException], bool] = lambda _exc: True,
|
|
*,
|
|
provider_name: str = "provider",
|
|
label: str = "rate limit",
|
|
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
|
|
"""Build a decorator that retries transient exceptions with backoff.
|
|
|
|
Args:
|
|
exception_type: Catch this exception class (or tuple of classes), e.g.
|
|
``openai.APIError`` or ``mistralai.client.errors.SDKError``.
|
|
should_retry: Predicate that decides whether a caught exception is
|
|
transient (and so retryable) vs. a permanent error of the same
|
|
class. Defaults to "always True" — appropriate when
|
|
``exception_type`` is already transient-specific (e.g. a 429 class).
|
|
provider_name: Used in log messages so operators can tell which
|
|
provider exhausted retries.
|
|
label: Short noun for the log message ("rate limit", "transient error")
|
|
so the line accurately names what was retried.
|
|
"""
|
|
|
|
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
|
@wraps(func)
|
|
async def wrapper(*args: Any, **kwargs: Any) -> T:
|
|
retry_delay = INITIAL_RETRY_DELAY
|
|
last_error: BaseException | None = None
|
|
|
|
for attempt in range(1, MAX_RETRIES + 1):
|
|
try:
|
|
return await func(*args, **kwargs)
|
|
# exception_type is constrained by the signature to a
|
|
# BaseException subclass or a tuple of them; the dynamic catch is
|
|
# the whole point of this reusable helper.
|
|
except exception_type as e: # NOSONAR(S5708)
|
|
if not should_retry(e):
|
|
raise
|
|
last_error = e
|
|
if attempt < MAX_RETRIES:
|
|
logger.warning(
|
|
"%s %s (attempt %d/%d): %r; retrying in %.1fs...",
|
|
provider_name,
|
|
label,
|
|
attempt,
|
|
MAX_RETRIES,
|
|
e,
|
|
retry_delay,
|
|
)
|
|
await anyio.sleep(retry_delay)
|
|
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
|
|
|
logger.error(
|
|
"%s %s not resolved after %d attempts",
|
|
provider_name,
|
|
label,
|
|
MAX_RETRIES,
|
|
)
|
|
if last_error is None: # pragma: no cover — loop above always sets this
|
|
raise RuntimeError("retry loop exited without capturing an error")
|
|
raise last_error
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
# Back-compat alias: the helper was originally rate-limit-specific.
|
|
retry_on_rate_limit = retry_on_transient
|