Addresses the Claude Code review on PR #772 plus the SonarCloud S1192 finding: - Extract `retry_on_rate_limit` into `nextcloud_mcp_server/providers/_retry.py` as a parametric decorator. OpenAI and Mistral now share the same backoff loop; future providers can reuse it without copy-paste. - New `tests/unit/providers/test_retry.py` covers the decorator: 429 retry + success, non-429 immediate re-raise, MAX_RETRIES exhaustion, default predicate, and unrelated exception passthrough. - Tighten Mistral SDK import to `from mistralai.client.errors import SDKError` (the canonical sub-path; the reviewer's `from mistralai.models import SDKError` does not exist in mistralai 2.4.5). - Replace `MistralProvider.close()`'s direct `__aexit__` call with a no-op + comment — the Speakeasy-generated client has no public close hook and the underlying httpx client is closed by GC. - Extract the duplicated "Embedding not supported" message to a module-level constant (SonarCloud S1192). - Align `Settings.get_embedding_model_name()` Bedrock check with the registry by also considering `bedrock_generation_model`. - Add the `mock_mistral_client` fixture to `test_mistral_no_embeddings_disabled` for parity with the rest of the file. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
78 lines
2.7 KiB
Python
78 lines
2.7 KiB
Python
"""Shared rate-limit retry helper for provider modules.
|
|
|
|
OpenAI and Mistral both retry on 429 with 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.
|
|
"""
|
|
|
|
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_rate_limit(
|
|
exception_type: type[BaseException],
|
|
is_rate_limit: Callable[[BaseException], bool] = lambda _exc: True,
|
|
*,
|
|
provider_name: str = "provider",
|
|
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
|
|
"""Build a decorator that retries on rate-limit exceptions.
|
|
|
|
Args:
|
|
exception_type: Catch this exception class (e.g. ``openai.RateLimitError``,
|
|
``mistralai.client.errors.SDKError``).
|
|
is_rate_limit: Predicate that decides whether a caught exception is
|
|
actually a rate-limit (vs. some other error of the same class).
|
|
Defaults to "always True" — appropriate when ``exception_type`` is
|
|
already a rate-limit-specific class.
|
|
provider_name: Used in log messages so operators can tell which
|
|
provider exhausted retries.
|
|
"""
|
|
|
|
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)
|
|
except exception_type as e:
|
|
if not is_rate_limit(e):
|
|
raise
|
|
last_error = e
|
|
if attempt < MAX_RETRIES:
|
|
logger.warning(
|
|
"%s rate limit hit (attempt %d/%d), retrying in %.1fs...",
|
|
provider_name,
|
|
attempt,
|
|
MAX_RETRIES,
|
|
retry_delay,
|
|
)
|
|
await anyio.sleep(retry_delay)
|
|
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
|
|
|
logger.error(
|
|
"%s rate limit exceeded after %d attempts", provider_name, MAX_RETRIES
|
|
)
|
|
assert last_error is not None # The loop above always assigns it.
|
|
raise last_error
|
|
|
|
return wrapper
|
|
|
|
return decorator
|