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>
104 lines
2.8 KiB
Python
104 lines
2.8 KiB
Python
"""Unit tests for the shared rate-limit retry decorator."""
|
|
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from nextcloud_mcp_server.providers import _retry
|
|
|
|
|
|
class _FakeError(Exception):
|
|
"""Stand-in for an SDK exception with an HTTP status code attached."""
|
|
|
|
def __init__(self, status_code: int):
|
|
super().__init__(f"status {status_code}")
|
|
self.status_code = status_code
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_real_sleep(monkeypatch):
|
|
"""Replace anyio.sleep with an awaitable no-op so retries don't waste time."""
|
|
monkeypatch.setattr(_retry.anyio, "sleep", AsyncMock(return_value=None))
|
|
|
|
|
|
@pytest.mark.unit
|
|
async def test_retry_succeeds_after_429():
|
|
"""A 429 followed by success returns the success value."""
|
|
calls = {"n": 0}
|
|
|
|
@_retry.retry_on_rate_limit(
|
|
_FakeError, is_rate_limit=lambda e: e.status_code == 429
|
|
)
|
|
async def flaky():
|
|
calls["n"] += 1
|
|
if calls["n"] < 3:
|
|
raise _FakeError(429)
|
|
return "ok"
|
|
|
|
result = await flaky()
|
|
assert result == "ok"
|
|
assert calls["n"] == 3
|
|
|
|
|
|
@pytest.mark.unit
|
|
async def test_retry_reraises_non_rate_limit_immediately():
|
|
"""A non-rate-limit error of the same class is re-raised on first hit."""
|
|
calls = {"n": 0}
|
|
|
|
@_retry.retry_on_rate_limit(
|
|
_FakeError, is_rate_limit=lambda e: e.status_code == 429
|
|
)
|
|
async def boom():
|
|
calls["n"] += 1
|
|
raise _FakeError(500)
|
|
|
|
with pytest.raises(_FakeError, match="status 500"):
|
|
await boom()
|
|
assert calls["n"] == 1 # No retries on non-429.
|
|
|
|
|
|
@pytest.mark.unit
|
|
async def test_retry_gives_up_after_max_retries():
|
|
"""After MAX_RETRIES failed attempts the last error is re-raised."""
|
|
calls = {"n": 0}
|
|
|
|
@_retry.retry_on_rate_limit(
|
|
_FakeError, is_rate_limit=lambda e: e.status_code == 429
|
|
)
|
|
async def always_429():
|
|
calls["n"] += 1
|
|
raise _FakeError(429)
|
|
|
|
with pytest.raises(_FakeError, match="status 429"):
|
|
await always_429()
|
|
assert calls["n"] == _retry.MAX_RETRIES
|
|
|
|
|
|
@pytest.mark.unit
|
|
async def test_retry_default_predicate_treats_all_as_rate_limit():
|
|
"""Default predicate (`lambda _: True`) retries every caught exception."""
|
|
calls = {"n": 0}
|
|
|
|
@_retry.retry_on_rate_limit(_FakeError)
|
|
async def fail_once():
|
|
calls["n"] += 1
|
|
if calls["n"] < 2:
|
|
raise _FakeError(503)
|
|
return "recovered"
|
|
|
|
result = await fail_once()
|
|
assert result == "recovered"
|
|
assert calls["n"] == 2
|
|
|
|
|
|
@pytest.mark.unit
|
|
async def test_retry_does_not_catch_unrelated_exceptions():
|
|
"""Exceptions of a different class bypass the decorator entirely."""
|
|
|
|
@_retry.retry_on_rate_limit(_FakeError)
|
|
async def value_error():
|
|
raise ValueError("nope")
|
|
|
|
with pytest.raises(ValueError, match="nope"):
|
|
await value_error()
|