refactor(providers): address PR #772 review — shared retry, cleaner imports, no-op close

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>
This commit is contained in:
Chris Coutinho
2026-05-08 17:46:32 +02:00
co-authored by Claude Opus 4.7
parent cf59663d9e
commit e360a7782b
6 changed files with 215 additions and 96 deletions
+1 -1
View File
@@ -151,7 +151,7 @@ async def test_mistral_get_dimension_unknown_model_detected(mock_mistral_client)
@pytest.mark.unit
async def test_mistral_no_embeddings_disabled():
async def test_mistral_no_embeddings_disabled(mock_mistral_client):
"""Setting embedding_model=None disables the embedding capability."""
provider = MistralProvider(api_key="test-key", embedding_model=None)
assert provider.supports_embeddings is False
+103
View File
@@ -0,0 +1,103 @@
"""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()