# apps/api/tests/test_ollama_health_monitor.py | 2026-04-25 @ Asia/Taipei # Created 2026-04-25 P1.1c by Claude Engineer-C # 2026-05-03 ogt: ADR-110 GCP 三層容災,HOST 更新為 GCP-A Primary """ OllamaHealthMonitor 單元測試 - P1.1c ===================================== 測試覆蓋: - 4 種健康狀態(HEALTHY / SLOW / DEGRADED / OFFLINE) - 連通性失敗 → OFFLINE - 推理超時(asyncio.TimeoutError / httpx.TimeoutException)→ DEGRADED - 推理回傳非 200 → DEGRADED - Redis 快取命中(from_cache=True) - Redis 快取失敗時降級直接執行(不 crash) - is_usable() 邏輯 測試分類:unit(mock httpx,無 DB / Redis 依賴) """ from __future__ import annotations import asyncio from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from src.services.ollama_endpoint_circuit_breaker import ( is_ollama_endpoint_blocked, record_ollama_endpoint_failure, reset_ollama_endpoint_cooldown_for_tests, ) from src.services.ollama_health_monitor import ( HealthReport, HealthStatus, OllamaHealthMonitor, get_ollama_health_monitor, reset_ollama_health_monitor, ) # ============================================================================= # Fixtures # ============================================================================= HOST = "http://34.143.170.20:11434" # GCP-A Primary(ADR-110 2026-05-03) HOST_LOCAL = "http://192.168.0.111:11434" # Local fallback(已移出 188 主路由) @pytest.fixture(autouse=True) def reset_singleton(): """每個測試後重置 singleton""" reset_ollama_endpoint_cooldown_for_tests() yield reset_ollama_health_monitor() reset_ollama_endpoint_cooldown_for_tests() @pytest.fixture def monitor(): return OllamaHealthMonitor() def _mock_tags_ok() -> MagicMock: """/api/tags 回傳 200""" resp = MagicMock() resp.status_code = 200 return resp def _mock_generate_ok(latency_s: float = 0.5) -> tuple[MagicMock, float]: """ /api/generate 回傳 200,模擬給定延遲。 返回 (response_mock, latency_s),由 test 自行控制 time.perf_counter patch。 """ resp = MagicMock() resp.status_code = 200 resp.json.return_value = {"response": "ok"} return resp # ============================================================================= # 層 1:連通性 # ============================================================================= class TestConnectivity: """_check_connectivity 各種情況""" @pytest.mark.asyncio async def test_connectivity_success(self, monitor): """/api/tags 200 → 連通性通過""" mock_resp = _mock_tags_ok() mock_client = AsyncMock() mock_client.get = AsyncMock(return_value=mock_resp) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) with patch("httpx.AsyncClient", return_value=mock_client): result = await monitor._check_connectivity(HOST) assert result is True @pytest.mark.asyncio async def test_connectivity_non_200(self, monitor): """/api/tags 非 200 → 連通性失敗""" mock_resp = MagicMock() mock_resp.status_code = 503 mock_client = AsyncMock() mock_client.get = AsyncMock(return_value=mock_resp) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) with patch("httpx.AsyncClient", return_value=mock_client): result = await monitor._check_connectivity(HOST) assert result is False @pytest.mark.asyncio async def test_run_checks_records_connectivity_failure_cooldown(self, monitor, monkeypatch): """連通性失敗會建立短暫 cooldown,避免重複撞同一個 upstream。""" monkeypatch.setattr(monitor, "_check_connectivity", AsyncMock(return_value=False)) report = await monitor._run_checks(HOST) assert report.status == HealthStatus.OFFLINE assert is_ollama_endpoint_blocked(HOST) @pytest.mark.asyncio async def test_run_checks_respects_existing_failure_cooldown(self, monitor, monkeypatch): """cooldown 中直接回報 OFFLINE,不再打 /api/tags。""" record_ollama_endpoint_failure(HOST) connectivity = AsyncMock(return_value=True) monkeypatch.setattr(monitor, "_check_connectivity", connectivity) report = await monitor._run_checks(HOST) assert report.status == HealthStatus.OFFLINE assert "cooldown" in report.reason connectivity.assert_not_awaited() @pytest.mark.asyncio async def test_connectivity_timeout(self, monitor): """連線 timeout → 返回 False(不 raise)""" mock_client = AsyncMock() mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("timeout")) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) with patch("httpx.AsyncClient", return_value=mock_client): result = await monitor._check_connectivity(HOST) assert result is False @pytest.mark.asyncio async def test_connectivity_connect_error(self, monitor): """連線拒絕 → 返回 False(不 raise)""" mock_client = AsyncMock() mock_client.get = AsyncMock(side_effect=httpx.ConnectError("refused")) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) with patch("httpx.AsyncClient", return_value=mock_client): result = await monitor._check_connectivity(HOST) assert result is False # ============================================================================= # 層 2:推理測試分級 # ============================================================================= class TestInference: """_check_inference 延遲分級""" def _make_mock_client(self, status_code: int = 200) -> AsyncMock: resp = MagicMock() resp.status_code = status_code resp.json.return_value = {"response": "ok"} mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=resp) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) return mock_client @pytest.mark.asyncio async def test_inference_healthy(self, monitor): """推理延遲 <10s → HEALTHY""" mock_client = self._make_mock_client() # 模擬 0.5s 延遲(< 10s threshold) call_count = [0] def _fake_perf_counter(): call_count[0] += 1 # 第一次呼叫(start),第二次呼叫(end = start + 0.5s) if call_count[0] == 1: return 0.0 return 0.5 with patch("httpx.AsyncClient", return_value=mock_client): with patch("src.services.ollama_health_monitor.time.perf_counter", _fake_perf_counter): report = await monitor._check_inference(HOST) assert report.status == HealthStatus.HEALTHY assert report.latency_ms == pytest.approx(500.0, abs=10) @pytest.mark.asyncio async def test_inference_slow(self, monitor): """推理延遲 10-30s → SLOW""" mock_client = self._make_mock_client() call_count = [0] def _fake_perf_counter(): call_count[0] += 1 if call_count[0] == 1: return 0.0 return 15.0 # 15s → SLOW zone with patch("httpx.AsyncClient", return_value=mock_client): with patch("src.services.ollama_health_monitor.time.perf_counter", _fake_perf_counter): report = await monitor._check_inference(HOST) assert report.status == HealthStatus.SLOW assert report.latency_ms == pytest.approx(15_000.0, abs=10) @pytest.mark.asyncio async def test_inference_degraded_by_latency(self, monitor): """推理延遲 >30s → DEGRADED""" mock_client = self._make_mock_client() call_count = [0] def _fake_perf_counter(): call_count[0] += 1 if call_count[0] == 1: return 0.0 return 32.0 # 32s → DEGRADED with patch("httpx.AsyncClient", return_value=mock_client): with patch("src.services.ollama_health_monitor.time.perf_counter", _fake_perf_counter): report = await monitor._check_inference(HOST) assert report.status == HealthStatus.DEGRADED @pytest.mark.asyncio async def test_inference_timeout_degraded(self, monitor): """推理 TimeoutException → DEGRADED(不 crash,不 OFFLINE)""" mock_client = AsyncMock() mock_client.post = AsyncMock(side_effect=httpx.TimeoutException("timeout")) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) with patch("httpx.AsyncClient", return_value=mock_client): report = await monitor._check_inference(HOST) assert report.status == HealthStatus.DEGRADED assert "超時" in report.reason or "timeout" in report.reason.lower() @pytest.mark.asyncio async def test_inference_asyncio_timeout_degraded(self, monitor): """推理 asyncio.TimeoutError → DEGRADED""" mock_client = AsyncMock() mock_client.post = AsyncMock(side_effect=asyncio.TimeoutError()) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) with patch("httpx.AsyncClient", return_value=mock_client): report = await monitor._check_inference(HOST) assert report.status == HealthStatus.DEGRADED @pytest.mark.asyncio async def test_inference_connect_error_degraded(self, monitor): """推理 ConnectError → DEGRADED(連通性已通過,視為 socket 瞬斷)""" mock_client = AsyncMock() mock_client.post = AsyncMock(side_effect=httpx.ConnectError("refused")) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) with patch("httpx.AsyncClient", return_value=mock_client): report = await monitor._check_inference(HOST) assert report.status == HealthStatus.DEGRADED @pytest.mark.asyncio async def test_inference_non_200_degraded(self, monitor): """推理回傳非 200 → DEGRADED""" mock_client = self._make_mock_client(status_code=503) call_count = [0] def _fake_perf_counter(): call_count[0] += 1 return float(call_count[0] - 1) * 0.1 with patch("httpx.AsyncClient", return_value=mock_client): with patch("src.services.ollama_health_monitor.time.perf_counter", _fake_perf_counter): report = await monitor._check_inference(HOST) assert report.status == HealthStatus.DEGRADED assert "503" in report.reason # ============================================================================= # check() 整合(含 Redis 快取) # ============================================================================= class TestCheckWithCache: """check() 方法含 Redis 快取邏輯""" @pytest.mark.asyncio async def test_check_offline_when_connectivity_fails(self, monitor): """連通性失敗 → 最終 OFFLINE""" with patch.object(monitor, "_check_connectivity", return_value=False): with patch.object(monitor, "_get_cached", return_value=None): with patch.object(monitor, "_set_cached", return_value=None): with patch.object(monitor, "_write_audit_log", return_value=None): report = await monitor.check(HOST) assert report.status == HealthStatus.OFFLINE assert report.host == HOST @pytest.mark.asyncio async def test_check_healthy_when_inference_fast(self, monitor): """連通性通過 + 推理快 → HEALTHY""" healthy_report = HealthReport( status=HealthStatus.HEALTHY, latency_ms=500.0, ) with patch.object(monitor, "_check_connectivity", return_value=True): with patch.object(monitor, "_check_inference", return_value=healthy_report): with patch.object(monitor, "_get_cached", return_value=None): with patch.object(monitor, "_set_cached", return_value=None): with patch.object(monitor, "_write_audit_log", return_value=None): report = await monitor.check(HOST) assert report.status == HealthStatus.HEALTHY assert report.host == HOST @pytest.mark.asyncio async def test_check_returns_cached(self, monitor): """快取命中 → 直接返回快取結果,from_cache=True""" cached = HealthReport( status=HealthStatus.HEALTHY, host=HOST, latency_ms=300.0, ) with patch.object(monitor, "_get_cached", return_value=cached): report = await monitor.check(HOST) assert report.from_cache is True assert report.status == HealthStatus.HEALTHY @pytest.mark.asyncio async def test_check_proceeds_when_cache_get_fails(self, monitor): """Redis get 失敗 → 降級直接執行 check(不 crash)""" with patch.object(monitor, "_get_cached", return_value=None): with patch.object(monitor, "_check_connectivity", return_value=False): with patch.object(monitor, "_set_cached", return_value=None): with patch.object(monitor, "_write_audit_log", return_value=None): report = await monitor.check(HOST) assert report.status == HealthStatus.OFFLINE # 正常降級,未 crash @pytest.mark.asyncio async def test_cache_set_failure_does_not_crash(self, monitor): """Redis set 失敗 → 靜默,結果仍正常返回""" healthy = HealthReport(status=HealthStatus.HEALTHY, latency_ms=200.0) with patch.object(monitor, "_get_cached", return_value=None): with patch.object(monitor, "_check_connectivity", return_value=True): with patch.object(monitor, "_check_inference", return_value=healthy): with patch.object(monitor, "_set_cached", side_effect=RuntimeError("Redis down")): with patch.object(monitor, "_write_audit_log", return_value=None): # 不應 raise report = await monitor.check(HOST) assert report.status == HealthStatus.HEALTHY # ============================================================================= # HealthReport 輔助方法 # ============================================================================= class TestHealthReport: """HealthReport dataclass 邏輯""" def test_is_usable_healthy(self): assert HealthReport(status=HealthStatus.HEALTHY).is_usable() is True def test_is_usable_slow(self): assert HealthReport(status=HealthStatus.SLOW).is_usable() is True def test_is_usable_degraded(self): assert HealthReport(status=HealthStatus.DEGRADED).is_usable() is True def test_is_usable_offline(self): assert HealthReport(status=HealthStatus.OFFLINE).is_usable() is False def test_to_dict_structure(self): report = HealthReport( status=HealthStatus.SLOW, host=HOST, latency_ms=15500.0, reason="slow zone", ) d = report.to_dict() assert d["status"] == "slow" assert d["host"] == HOST assert d["latency_ms"] == 15500.0 assert d["reason"] == "slow zone" # ============================================================================= # Singleton # ============================================================================= def test_singleton_returns_same_instance(): m1 = get_ollama_health_monitor() m2 = get_ollama_health_monitor() assert m1 is m2 def test_reset_singleton_gives_new_instance(): m1 = get_ollama_health_monitor() reset_ollama_health_monitor() m2 = get_ollama_health_monitor() assert m1 is not m2