fix: navidrome subsonic api errors incorrectly tripping circuit breaker (#9)
This commit is contained in:
@@ -144,8 +144,8 @@ async def test_circuit_still_opens_for_real_errors_amid_rate_limits():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_breaking_in_half_open_reopens_circuit():
|
||||
"""Non-breaking exceptions in HALF_OPEN must still reopen the circuit."""
|
||||
async def test_non_breaking_in_half_open_stays_half_open():
|
||||
"""Non-breaking exceptions in HALF_OPEN keep the circuit HALF_OPEN (service is reachable)."""
|
||||
cb = CircuitBreaker(failure_threshold=2, success_threshold=2, timeout=0.01, name="test-half-open")
|
||||
|
||||
for _ in range(2):
|
||||
@@ -170,7 +170,7 @@ async def test_non_breaking_in_half_open_reopens_circuit():
|
||||
with pytest.raises(_RateLimited):
|
||||
await rate_limited_in_half_open()
|
||||
|
||||
assert cb.state == CircuitState.OPEN
|
||||
assert cb.state == CircuitState.HALF_OPEN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from core.exceptions import ExternalServiceError, NavidromeApiError, NavidromeAuthError
|
||||
from core.exceptions import ExternalServiceError, NavidromeApiError, NavidromeAuthError, NavidromeSubsonicError
|
||||
from repositories.navidrome_repository import _navidrome_circuit_breaker
|
||||
from repositories.navidrome_models import (
|
||||
SubsonicAlbum,
|
||||
@@ -86,14 +86,14 @@ class TestParseSubsonicResponse:
|
||||
resp = parse_subsonic_response(data)
|
||||
assert resp["status"] == "ok"
|
||||
|
||||
def test_error_status_raises_api_error(self):
|
||||
def test_error_status_raises_subsonic_error(self):
|
||||
data = {
|
||||
"subsonic-response": {
|
||||
"status": "failed",
|
||||
"error": {"code": 70, "message": "Not found"},
|
||||
}
|
||||
}
|
||||
with pytest.raises(NavidromeApiError, match="Not found"):
|
||||
with pytest.raises(NavidromeSubsonicError, match="Not found"):
|
||||
parse_subsonic_response(data)
|
||||
|
||||
def test_auth_error_code_40(self):
|
||||
@@ -333,6 +333,63 @@ class TestErrorHandling:
|
||||
await repo._request("/rest/ping")
|
||||
|
||||
|
||||
class TestCircuitBreakerNonBreaking:
|
||||
"""Verify NavidromeSubsonicError doesn't trip the circuit breaker."""
|
||||
|
||||
def setup_method(self):
|
||||
_navidrome_circuit_breaker.reset()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subsonic_error_does_not_open_circuit_breaker(self):
|
||||
"""Repeated SubsonicErrors (e.g. 'Library not found') should NOT open the CB."""
|
||||
repo, client, _ = _make_repo()
|
||||
error_envelope = {
|
||||
"subsonic-response": {
|
||||
"status": "failed",
|
||||
"error": {"code": 70, "message": "Library not found or empty"},
|
||||
}
|
||||
}
|
||||
client.get = AsyncMock(return_value=_mock_response(error_envelope))
|
||||
|
||||
with patch("infrastructure.resilience.retry.asyncio.sleep", new_callable=AsyncMock):
|
||||
for _ in range(10):
|
||||
with pytest.raises(NavidromeSubsonicError):
|
||||
await repo._request("/rest/getAlbumList2")
|
||||
|
||||
from infrastructure.resilience.retry import CircuitState
|
||||
assert _navidrome_circuit_breaker.state == CircuitState.CLOSED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_error_still_trips_circuit_breaker(self):
|
||||
"""Auth errors (NavidromeAuthError) should still record CB failures."""
|
||||
from infrastructure.resilience.retry import CircuitOpenError, CircuitState
|
||||
|
||||
repo, client, _ = _make_repo()
|
||||
client.get = AsyncMock(return_value=_mock_response({}, status_code=401))
|
||||
|
||||
with patch("infrastructure.resilience.retry.asyncio.sleep", new_callable=AsyncMock):
|
||||
for _ in range(10):
|
||||
with pytest.raises((NavidromeAuthError, ExternalServiceError, CircuitOpenError)):
|
||||
await repo._request("/rest/ping")
|
||||
|
||||
assert _navidrome_circuit_breaker.state == CircuitState.OPEN
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_error_still_trips_circuit_breaker(self):
|
||||
"""Transport-level errors should still record CB failures."""
|
||||
from infrastructure.resilience.retry import CircuitOpenError, CircuitState
|
||||
|
||||
repo, client, _ = _make_repo()
|
||||
client.get = AsyncMock(side_effect=httpx.ConnectError("refused"))
|
||||
|
||||
with patch("infrastructure.resilience.retry.asyncio.sleep", new_callable=AsyncMock):
|
||||
for _ in range(10):
|
||||
with pytest.raises((ExternalServiceError, CircuitOpenError)):
|
||||
await repo._request("/rest/ping")
|
||||
|
||||
assert _navidrome_circuit_breaker.state == CircuitState.OPEN
|
||||
|
||||
|
||||
class TestValidateConnection:
|
||||
def setup_method(self):
|
||||
_navidrome_circuit_breaker.reset()
|
||||
|
||||
@@ -91,6 +91,25 @@ class TestLibraryAlbums:
|
||||
assert data["items"][0]["navidrome_id"] == "a1"
|
||||
assert data["total"] == 1
|
||||
|
||||
def test_get_albums_stats_fallback(self, library_client, mock_library_service):
|
||||
"""When stats fails, albums still returns with heuristic total."""
|
||||
mock_library_service.get_albums = AsyncMock(return_value=[_album_summary(id=f"a{i}") for i in range(48)])
|
||||
mock_library_service.get_stats = AsyncMock(side_effect=ExternalServiceError("Library not found"))
|
||||
resp = library_client.get("/navidrome/albums?limit=48")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 48
|
||||
assert data["total"] == 49 # offset(0) + 48 + 1 (full page heuristic)
|
||||
|
||||
def test_get_albums_stats_fallback_partial_page(self, library_client, mock_library_service):
|
||||
"""Partial page + stats failure → total = offset + len(items), no +1."""
|
||||
mock_library_service.get_albums = AsyncMock(return_value=[_album_summary(id=f"a{i}") for i in range(5)])
|
||||
mock_library_service.get_stats = AsyncMock(side_effect=ExternalServiceError("down"))
|
||||
resp = library_client.get("/navidrome/albums?limit=48")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 5
|
||||
|
||||
def test_get_album_detail(self, library_client):
|
||||
resp = library_client.get("/navidrome/albums/a1")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -26,7 +26,10 @@ def _build_app() -> FastAPI:
|
||||
|
||||
@app.get("/raise-circuit")
|
||||
async def raise_circuit():
|
||||
raise CircuitOpenError("JellyfinRepository after 5 failures")
|
||||
raise CircuitOpenError(
|
||||
"Circuit breaker 'jellyfin' is OPEN",
|
||||
breaker_name="jellyfin",
|
||||
)
|
||||
|
||||
app.add_exception_handler(ExternalServiceError, external_service_error_handler)
|
||||
app.add_exception_handler(CircuitOpenError, circuit_open_error_handler)
|
||||
@@ -70,5 +73,23 @@ async def test_circuit_open_error_hides_details():
|
||||
|
||||
body = resp.json()
|
||||
assert resp.status_code == 503
|
||||
assert body["error"]["message"] == "Service temporarily unavailable due to repeated connection failures. Check your settings or wait for the service to recover."
|
||||
assert "JellyfinRepository" not in resp.text
|
||||
assert body["error"]["message"] == "Jellyfin is temporarily unavailable due to repeated connection failures. Check your settings or wait for the service to recover."
|
||||
assert "circuit breaker" not in resp.text.lower() or "CIRCUIT_BREAKER_OPEN" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_circuit_open_error_without_breaker_name_falls_back():
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/raise-circuit-unnamed")
|
||||
async def raise_circuit_unnamed():
|
||||
raise CircuitOpenError("CB tripped")
|
||||
|
||||
app.add_exception_handler(CircuitOpenError, circuit_open_error_handler)
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/raise-circuit-unnamed")
|
||||
|
||||
body = resp.json()
|
||||
assert resp.status_code == 503
|
||||
assert body["error"]["message"].startswith("Service is temporarily unavailable")
|
||||
|
||||
Reference in New Issue
Block a user