mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
fix(proxy): release the health check save window lock on failure or cancel
This commit is contained in:
parent
eddb29d90e
commit
f980b94923
4 changed files with 278 additions and 136 deletions
|
|
@ -7,7 +7,7 @@ import secrets
|
|||
import time
|
||||
import traceback
|
||||
from collections.abc import Iterable, Mapping
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Final, Literal, TypedDict, cast
|
||||
|
||||
import fastapi
|
||||
|
|
@ -41,6 +41,7 @@ from litellm.proxy.auth.auth_utils import (
|
|||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
|
||||
from litellm.proxy.db.health_check_latest import LatestHealthCheckRow
|
||||
from litellm.proxy.db.proxy_worker_heartbeat import count_live_proxy_workers
|
||||
from litellm.proxy.health_check import (
|
||||
ADMIN_ONLY_HEALTH_DISPLAY_PARAMS,
|
||||
|
|
@ -697,13 +698,42 @@ def _aggregate_health_check_results(
|
|||
return model_results
|
||||
|
||||
|
||||
class _AggregatedHealthResult(TypedDict):
|
||||
"""One entry of ``_aggregate_health_check_results``: a model's counts for this cycle."""
|
||||
|
||||
model_name: ReadOnly[str]
|
||||
model_id: ReadOnly[str | None]
|
||||
healthy_count: ReadOnly[int]
|
||||
unhealthy_count: ReadOnly[int]
|
||||
error_message: ReadOnly[str | None]
|
||||
|
||||
|
||||
def _new_health_status(result: _AggregatedHealthResult) -> str:
|
||||
return "healthy" if result["healthy_count"] > 0 else "unhealthy"
|
||||
|
||||
|
||||
def _should_persist_health_check_result(
|
||||
result: _AggregatedHealthResult, latest_checks_map: Mapping[str, LatestHealthCheckRow]
|
||||
) -> bool:
|
||||
"""
|
||||
True when this result has to be written: no previous row, the status changed, or the
|
||||
previous row is older than one hour (periodic refresh while the status is stable).
|
||||
"""
|
||||
lookup_key: Final = result["model_id"] if result["model_id"] else result["model_name"]
|
||||
last_check: Final = latest_checks_map.get(lookup_key)
|
||||
if last_check is None or last_check.status != _new_health_status(result):
|
||||
return True
|
||||
time_since_last_check: Final = (datetime.now(timezone.utc) - last_check.checked_at).total_seconds()
|
||||
return time_since_last_check >= 3600 # 1 hour threshold
|
||||
|
||||
|
||||
async def _save_health_check_results_if_changed(
|
||||
prisma_client,
|
||||
model_results: dict,
|
||||
latest_checks_map: dict,
|
||||
start_time: float,
|
||||
checked_by: str | None = None,
|
||||
):
|
||||
) -> bool:
|
||||
"""
|
||||
Save health check results to database, but only if status changed or >1 hour since last save.
|
||||
|
||||
|
|
@ -714,47 +744,39 @@ async def _save_health_check_results_if_changed(
|
|||
- Status changes: Immediate write (no delay)
|
||||
- Result: ~92% reduction in DB writes for stable systems, while maintaining real-time updates on changes
|
||||
|
||||
The writes are awaited rather than detached so the caller learns whether this cycle's
|
||||
persistence completed.
|
||||
|
||||
Args:
|
||||
prisma_client: Database client
|
||||
model_results: Dictionary of aggregated health check results per model
|
||||
latest_checks_map: Dictionary mapping model_id/model_name to latest health check
|
||||
start_time: Start time of health check for calculating response time
|
||||
checked_by: Identifier for who/what performed the check
|
||||
|
||||
Returns:
|
||||
True when every row that needed writing was written (including when nothing needed
|
||||
writing); False when any write failed.
|
||||
"""
|
||||
for result in model_results.values():
|
||||
new_status = "healthy" if result["healthy_count"] > 0 else "unhealthy"
|
||||
|
||||
# Check if we should save this result
|
||||
should_save = True
|
||||
lookup_key = result["model_id"] if result["model_id"] else result["model_name"]
|
||||
if lookup_key in latest_checks_map:
|
||||
last_check = latest_checks_map[lookup_key]
|
||||
# Only save if status changed or if it's been a while since last check
|
||||
if last_check.status == new_status:
|
||||
# Check if last check was recent (within 1 hour)
|
||||
if last_check.checked_at:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
time_since_last_check = (datetime.now(timezone.utc) - last_check.checked_at).total_seconds()
|
||||
# Only skip if status unchanged AND checked recently (within 1 hour)
|
||||
# This ensures we still get periodic updates even if status is stable
|
||||
if time_since_last_check < 3600: # 1 hour threshold
|
||||
should_save = False
|
||||
|
||||
if should_save:
|
||||
asyncio.create_task(
|
||||
prisma_client.save_health_check_result(
|
||||
model_name=result["model_name"],
|
||||
model_id=result["model_id"],
|
||||
status=new_status,
|
||||
healthy_count=result["healthy_count"],
|
||||
unhealthy_count=result["unhealthy_count"],
|
||||
error_message=result["error_message"],
|
||||
response_time_ms=(time.time() - start_time) * 1000,
|
||||
details=None,
|
||||
checked_by=checked_by,
|
||||
)
|
||||
)
|
||||
to_write: Final = tuple(
|
||||
result for result in model_results.values() if _should_persist_health_check_result(result, latest_checks_map)
|
||||
)
|
||||
writes: Final = tuple(
|
||||
prisma_client.save_health_check_result(
|
||||
model_name=result["model_name"],
|
||||
model_id=result["model_id"],
|
||||
status=_new_health_status(result),
|
||||
healthy_count=result["healthy_count"],
|
||||
unhealthy_count=result["unhealthy_count"],
|
||||
error_message=result["error_message"],
|
||||
response_time_ms=(time.time() - start_time) * 1000,
|
||||
details=None,
|
||||
checked_by=checked_by,
|
||||
)
|
||||
for result in to_write
|
||||
)
|
||||
rows: Final = await asyncio.gather(*writes)
|
||||
return all(row is not None for row in rows)
|
||||
|
||||
|
||||
async def _save_background_health_checks_to_db(
|
||||
|
|
@ -764,7 +786,7 @@ async def _save_background_health_checks_to_db(
|
|||
unhealthy_endpoints: list,
|
||||
start_time: float,
|
||||
checked_by: str | None = None,
|
||||
):
|
||||
) -> bool:
|
||||
"""
|
||||
Save background health check results to database for each model.
|
||||
|
||||
|
|
@ -773,9 +795,13 @@ async def _save_background_health_checks_to_db(
|
|||
|
||||
OPTIMIZATION: Only saves to database if the status has changed from the last saved check.
|
||||
This dramatically reduces database writes when health status remains stable.
|
||||
|
||||
Returns:
|
||||
True when this cycle's persistence completed; False when it was skipped or any step
|
||||
failed. Never raises: a database failure must not break the health check loop.
|
||||
"""
|
||||
if prisma_client is None:
|
||||
return
|
||||
return False
|
||||
|
||||
try:
|
||||
# Step 1: Build mapping from model parameter to model info
|
||||
|
|
@ -798,7 +824,7 @@ async def _save_background_health_checks_to_db(
|
|||
latest_checks_map[key] = check
|
||||
|
||||
# Step 4: Save aggregated results, but only if status changed
|
||||
await _save_health_check_results_if_changed(
|
||||
return await _save_health_check_results_if_changed(
|
||||
prisma_client,
|
||||
model_results,
|
||||
latest_checks_map,
|
||||
|
|
@ -808,6 +834,7 @@ async def _save_background_health_checks_to_db(
|
|||
except Exception as db_error:
|
||||
verbose_proxy_logger.warning("Failed to save background health checks to database: %s", db_error)
|
||||
# Continue execution - don't let database save failure break health checks
|
||||
return False
|
||||
|
||||
|
||||
_PROXY_ADMIN_ROLES: Final = frozenset(
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ if TYPE_CHECKING:
|
|||
from prisma import models as prisma_models
|
||||
|
||||
from litellm.integrations.opentelemetry import OpenTelemetry
|
||||
from litellm.proxy.health_check_utils.shared_health_check_manager import SharedHealthCheckManager
|
||||
|
||||
Span = _Span | Any
|
||||
else:
|
||||
|
|
@ -3592,35 +3593,49 @@ async def _run_direct_health_check_with_instrumentation(
|
|||
|
||||
|
||||
async def _window_gated_health_check_db_save(
|
||||
save: Callable[[], Awaitable[None]],
|
||||
save: Callable[[], Awaitable[bool]],
|
||||
pod_lock_manager: PodLockManager | None,
|
||||
lock_ttl: int | None,
|
||||
) -> None:
|
||||
"""
|
||||
Persist at most once per window fleet-wide: the lock is the "this window's save is
|
||||
done" marker, so it is deliberately never released and expires with the interval.
|
||||
Persist at most once per window fleet-wide. A completed save keeps the lock as the
|
||||
"this window's save is done" marker, so it is deliberately never released and expires
|
||||
with the interval. A save that reports failure or is cancelled releases the lock so
|
||||
another pod's cycle in the same window can retry, instead of the fleet going a whole
|
||||
window without a write.
|
||||
"""
|
||||
if pod_lock_manager is not None and pod_lock_manager.redis_cache is not None:
|
||||
acquired: Final = await pod_lock_manager.acquire_lock(
|
||||
cronjob_id=BACKGROUND_HEALTH_CHECK_DB_SAVE_JOB_NAME,
|
||||
ttl=lock_ttl,
|
||||
allow_reentrant=False,
|
||||
if pod_lock_manager is None or pod_lock_manager.redis_cache is None:
|
||||
await save()
|
||||
return
|
||||
acquired: Final = await pod_lock_manager.acquire_lock(
|
||||
cronjob_id=BACKGROUND_HEALTH_CHECK_DB_SAVE_JOB_NAME,
|
||||
ttl=lock_ttl,
|
||||
allow_reentrant=False,
|
||||
)
|
||||
if not acquired:
|
||||
verbose_proxy_logger.debug("background_health_check_db_save_skipped another pod persisted this window")
|
||||
return
|
||||
try:
|
||||
persisted: Final = await save()
|
||||
except BaseException:
|
||||
await pod_lock_manager.release_lock(cronjob_id=BACKGROUND_HEALTH_CHECK_DB_SAVE_JOB_NAME)
|
||||
raise
|
||||
if not persisted:
|
||||
verbose_proxy_logger.warning(
|
||||
"background_health_check_db_save_incomplete released the window lock so another pod can retry"
|
||||
)
|
||||
if not acquired:
|
||||
verbose_proxy_logger.debug("background_health_check_db_save_skipped another pod persisted this window")
|
||||
return
|
||||
await save()
|
||||
await pod_lock_manager.release_lock(cronjob_id=BACKGROUND_HEALTH_CHECK_DB_SAVE_JOB_NAME)
|
||||
|
||||
|
||||
def _schedule_background_health_check_db_save(
|
||||
prisma_client,
|
||||
shared_health_manager,
|
||||
prisma_client: PrismaClient | None,
|
||||
shared_health_manager: "SharedHealthCheckManager | None",
|
||||
model_list: list,
|
||||
healthy_endpoints: list,
|
||||
unhealthy_endpoints: list,
|
||||
pod_lock_manager: PodLockManager | None = None,
|
||||
lock_ttl: int | None = None,
|
||||
):
|
||||
) -> None:
|
||||
"""Fire-and-forget: persist health check results to DB if prisma is available."""
|
||||
if prisma_client is None:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -112,13 +112,11 @@ async def test_run_direct_health_check_with_instrumentation_returns_results(
|
|||
lambda _gs: {},
|
||||
)
|
||||
|
||||
healthy, unhealthy, exceptions = (
|
||||
await _run_direct_health_check_with_instrumentation(
|
||||
model_list=[{"model_name": "gpt-4"}],
|
||||
details=False,
|
||||
max_concurrency=1,
|
||||
instrumentation_context={"source": "test"},
|
||||
)
|
||||
healthy, unhealthy, exceptions = await _run_direct_health_check_with_instrumentation(
|
||||
model_list=[{"model_name": "gpt-4"}],
|
||||
details=False,
|
||||
max_concurrency=1,
|
||||
instrumentation_context={"source": "test"},
|
||||
)
|
||||
|
||||
assert normalize(
|
||||
|
|
@ -254,11 +252,12 @@ def _lock_manager(redis_cache, acquired):
|
|||
return manager
|
||||
|
||||
|
||||
def _capture_saves(monkeypatch):
|
||||
def _capture_saves(monkeypatch, persisted=True):
|
||||
saves = []
|
||||
|
||||
async def _fake_save(*_args, **kwargs):
|
||||
saves.append(kwargs)
|
||||
return persisted
|
||||
|
||||
import litellm.proxy.health_endpoints._health_endpoints as he
|
||||
|
||||
|
|
@ -266,6 +265,15 @@ def _capture_saves(monkeypatch):
|
|||
return saves
|
||||
|
||||
|
||||
def _cancel_during_save(monkeypatch):
|
||||
async def _fake_save(*_args, **_kwargs):
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
import litellm.proxy.health_endpoints._health_endpoints as he
|
||||
|
||||
monkeypatch.setattr(he, "_save_background_health_checks_to_db", _fake_save)
|
||||
|
||||
|
||||
def _schedule_with(lock_manager):
|
||||
_schedule_background_health_check_db_save(
|
||||
prisma_client=MagicMock(),
|
||||
|
|
@ -315,15 +323,56 @@ async def test_schedule_background_health_check_db_save_holds_the_window_lock_fo
|
|||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_background_health_check_db_save_releases_the_window_lock_when_the_save_reports_failure(
|
||||
monkeypatch,
|
||||
):
|
||||
"""A failed save must not burn the window: release the lock so another pod's cycle can retry."""
|
||||
saves = _capture_saves(monkeypatch, persisted=False)
|
||||
lock_manager = _lock_manager(redis_cache=MagicMock(), acquired=True)
|
||||
|
||||
_schedule_with(lock_manager)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert normalize(
|
||||
{
|
||||
"saves": len(saves),
|
||||
"release_request": lock_manager.release_lock.await_args.kwargs,
|
||||
"release_count": lock_manager.release_lock.await_count,
|
||||
}
|
||||
) == {
|
||||
"saves": 1,
|
||||
"release_request": {"cronjob_id": BACKGROUND_HEALTH_CHECK_DB_SAVE_JOB_NAME},
|
||||
"release_count": 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_background_health_check_db_save_releases_the_window_lock_when_the_save_is_cancelled(
|
||||
monkeypatch,
|
||||
):
|
||||
"""A pod shutting down mid-save releases the lock instead of holding it until the TTL."""
|
||||
_cancel_during_save(monkeypatch)
|
||||
lock_manager = _lock_manager(redis_cache=MagicMock(), acquired=True)
|
||||
|
||||
_schedule_with(lock_manager)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert (
|
||||
lock_manager.release_lock.await_args.kwargs,
|
||||
lock_manager.release_lock.await_count,
|
||||
) == ({"cronjob_id": BACKGROUND_HEALTH_CHECK_DB_SAVE_JOB_NAME}, 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_background_health_check_db_save_runs_ungated_without_redis(monkeypatch):
|
||||
saves = _capture_saves(monkeypatch)
|
||||
saves = _capture_saves(monkeypatch, persisted=False)
|
||||
lock_manager = _lock_manager(redis_cache=None, acquired=True)
|
||||
|
||||
_schedule_with(lock_manager)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert (len(saves), lock_manager.acquire_lock.await_count) == (1, 0)
|
||||
assert (len(saves), lock_manager.acquire_lock.await_count, lock_manager.release_lock.await_count) == (1, 0, 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -400,13 +449,9 @@ def test_write_health_state_to_router_cache_sets_states(monkeypatch):
|
|||
|
||||
_write_health_state_to_router_cache(healthy, unhealthy, exceptions)
|
||||
|
||||
fake_router.health_state_cache.set_deployment_health_states.assert_called_once_with(
|
||||
fake_states
|
||||
)
|
||||
fake_router.health_state_cache.set_deployment_health_states.assert_called_once_with(fake_states)
|
||||
|
||||
call_args = fake_router.health_state_cache.set_deployment_health_states.call_args[
|
||||
0
|
||||
][0]
|
||||
call_args = fake_router.health_state_cache.set_deployment_health_states.call_args[0][0]
|
||||
assert normalize(
|
||||
{
|
||||
"states_keys": sorted(call_args.keys()),
|
||||
|
|
@ -448,9 +493,7 @@ def test_write_health_state_to_router_cache_populates_for_listing_filter(monkeyp
|
|||
fake_router.cooldown_time = 30
|
||||
|
||||
monkeypatch.setattr(proxy_server, "llm_router", fake_router)
|
||||
monkeypatch.setattr(
|
||||
proxy_server, "general_settings", {"model_list_healthy_only": True}
|
||||
)
|
||||
monkeypatch.setattr(proxy_server, "general_settings", {"model_list_healthy_only": True})
|
||||
|
||||
fake_states = {"m1": {"is_healthy": True}, "m2": {"is_healthy": False}}
|
||||
|
||||
|
|
@ -484,9 +527,7 @@ def test_write_health_state_to_router_cache_populates_for_listing_filter(monkeyp
|
|||
{"m2": SimpleNamespace(status_code=500)},
|
||||
)
|
||||
|
||||
fake_router.health_state_cache.set_deployment_health_states.assert_called_once_with(
|
||||
fake_states
|
||||
)
|
||||
fake_router.health_state_cache.set_deployment_health_states.assert_called_once_with(fake_states)
|
||||
assert cooldowns == []
|
||||
assert failures == []
|
||||
|
||||
|
|
@ -496,9 +537,7 @@ def test_write_health_state_to_router_cache_swallows_internal_failures(monkeypat
|
|||
fake_router = MagicMock()
|
||||
fake_router.enable_health_check_routing = True
|
||||
fake_router.health_check_ignore_transient_errors = False
|
||||
fake_router.health_state_cache.set_deployment_health_states.side_effect = (
|
||||
RuntimeError("cache exploded")
|
||||
)
|
||||
fake_router.health_state_cache.set_deployment_health_states.side_effect = RuntimeError("cache exploded")
|
||||
|
||||
monkeypatch.setattr(proxy_server, "llm_router", fake_router)
|
||||
|
||||
|
|
@ -528,9 +567,7 @@ async def test_adaptive_router_flusher_loop_flushes_each_router(monkeypatch):
|
|||
from litellm.types.router import TaggedPreRoutingStrategy
|
||||
|
||||
fake_router = MagicMock()
|
||||
fake_router.adaptive_routers = {
|
||||
"alpha": [TaggedPreRoutingStrategy(tags=(), strategy=fake_ar)]
|
||||
}
|
||||
fake_router.adaptive_routers = {"alpha": [TaggedPreRoutingStrategy(tags=(), strategy=fake_ar)]}
|
||||
|
||||
monkeypatch.setattr(proxy_server, "llm_router", fake_router)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
|
||||
|
|
@ -628,12 +665,8 @@ async def test_run_background_health_check_runs_one_cycle_then_cancels(monkeypat
|
|||
"_run_direct_health_check_with_instrumentation",
|
||||
_fake_direct,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server, "_schedule_background_health_check_db_save", lambda *a, **kw: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server, "_write_health_state_to_router_cache", lambda *a, **kw: None
|
||||
)
|
||||
monkeypatch.setattr(proxy_server, "_schedule_background_health_check_db_save", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(proxy_server, "_write_health_state_to_router_cache", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(
|
||||
proxy_server,
|
||||
"health_check_filter_kwargs_from_general_settings",
|
||||
|
|
@ -711,12 +744,8 @@ async def test_run_background_health_check_probes_only_listed_model_groups(monke
|
|||
"_run_direct_health_check_with_instrumentation",
|
||||
_fake_direct,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server, "_schedule_background_health_check_db_save", lambda *a, **kw: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server, "_write_health_state_to_router_cache", lambda *a, **kw: None
|
||||
)
|
||||
monkeypatch.setattr(proxy_server, "_schedule_background_health_check_db_save", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(proxy_server, "_write_health_state_to_router_cache", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(
|
||||
proxy_server,
|
||||
"health_check_filter_kwargs_from_general_settings",
|
||||
|
|
|
|||
|
|
@ -24,12 +24,8 @@ from litellm.proxy.utils import PrismaClient
|
|||
def mock_prisma():
|
||||
"""Simplified mock PrismaClient with bound methods"""
|
||||
client = MagicMock()
|
||||
client.db.litellm_healthchecktable.create = AsyncMock(
|
||||
return_value={"id": "test-id"}
|
||||
)
|
||||
client.db.litellm_healthchecktable.find_many = AsyncMock(
|
||||
return_value=[{"id": "1", "model_name": "test"}]
|
||||
)
|
||||
client.db.litellm_healthchecktable.create = AsyncMock(return_value={"id": "test-id"})
|
||||
client.db.litellm_healthchecktable.find_many = AsyncMock(return_value=[{"id": "1", "model_name": "test"}])
|
||||
|
||||
# Bind actual methods
|
||||
import types
|
||||
|
|
@ -55,14 +51,10 @@ def mock_prisma():
|
|||
("healthy", 1, 0, False), # Database error case
|
||||
],
|
||||
)
|
||||
async def test_save_health_check_result(
|
||||
mock_prisma, status, healthy, unhealthy, should_succeed
|
||||
):
|
||||
async def test_save_health_check_result(mock_prisma, status, healthy, unhealthy, should_succeed):
|
||||
"""Test health check result saving with various scenarios"""
|
||||
if not should_succeed:
|
||||
mock_prisma.db.litellm_healthchecktable.create.side_effect = Exception(
|
||||
"DB Error"
|
||||
)
|
||||
mock_prisma.db.litellm_healthchecktable.create.side_effect = Exception("DB Error")
|
||||
|
||||
result = await mock_prisma.save_health_check_result(
|
||||
model_name="test-model",
|
||||
|
|
@ -190,9 +182,7 @@ def test_aggregate_health_check_results():
|
|||
{"model": "gpt-4", "error": "Rate limit exceeded"},
|
||||
]
|
||||
|
||||
result = _aggregate_health_check_results(
|
||||
model_param_to_info, healthy_endpoints, unhealthy_endpoints
|
||||
)
|
||||
result = _aggregate_health_check_results(model_param_to_info, healthy_endpoints, unhealthy_endpoints)
|
||||
|
||||
# Check gpt-3.5-turbo is healthy
|
||||
gpt35_key = ("model-123", "gpt-3.5-turbo")
|
||||
|
|
@ -223,9 +213,7 @@ def test_aggregate_health_check_results_multiple_endpoints():
|
|||
]
|
||||
unhealthy_endpoints = []
|
||||
|
||||
result = _aggregate_health_check_results(
|
||||
model_param_to_info, healthy_endpoints, unhealthy_endpoints
|
||||
)
|
||||
result = _aggregate_health_check_results(model_param_to_info, healthy_endpoints, unhealthy_endpoints)
|
||||
|
||||
key = ("model-123", "gpt-3.5-turbo")
|
||||
assert result[key]["healthy_count"] == 2
|
||||
|
|
@ -401,7 +389,7 @@ async def test_save_background_health_checks_to_db():
|
|||
|
||||
start_time = 1234567890.0
|
||||
|
||||
await _save_background_health_checks_to_db(
|
||||
persisted = await _save_background_health_checks_to_db(
|
||||
mock_prisma,
|
||||
model_list,
|
||||
healthy_endpoints,
|
||||
|
|
@ -410,7 +398,8 @@ async def test_save_background_health_checks_to_db():
|
|||
"background_health_check",
|
||||
)
|
||||
|
||||
# Should call get_all_latest_health_checks and save_health_check_result
|
||||
# Should call get_all_latest_health_checks and save_health_check_result, and report completion
|
||||
assert persisted is True
|
||||
mock_prisma.get_all_latest_health_checks.assert_called_once()
|
||||
mock_prisma.save_health_check_result.assert_called_once()
|
||||
|
||||
|
|
@ -421,22 +410,112 @@ async def test_save_background_health_checks_to_db():
|
|||
assert call_kwargs["checked_by"] == "background_health_check"
|
||||
|
||||
|
||||
def _two_model_results():
|
||||
return {
|
||||
("model-1", "gpt-4"): {
|
||||
"model_name": "gpt-4",
|
||||
"model_id": "model-1",
|
||||
"healthy_count": 1,
|
||||
"unhealthy_count": 0,
|
||||
"error_message": None,
|
||||
},
|
||||
("model-2", "gpt-4o"): {
|
||||
"model_name": "gpt-4o",
|
||||
"model_id": "model-2",
|
||||
"healthy_count": 0,
|
||||
"unhealthy_count": 1,
|
||||
"error_message": "boom",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_health_check_results_if_changed_awaits_every_write_and_reports_success():
|
||||
"""Writes are awaited, not detached, so the caller can tell the cycle's persistence completed."""
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.save_health_check_result = AsyncMock(return_value={"id": "row"})
|
||||
|
||||
persisted = await _save_health_check_results_if_changed(
|
||||
mock_prisma, _two_model_results(), {}, 1234567890.0, "background_health_check"
|
||||
)
|
||||
|
||||
assert (persisted, mock_prisma.save_health_check_result.await_count) == (True, 2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_health_check_results_if_changed_reports_failure_when_a_write_returns_none():
|
||||
"""save_health_check_result swallows DB errors and returns None; that must surface as False."""
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.save_health_check_result = AsyncMock(side_effect=[{"id": "row"}, None])
|
||||
|
||||
persisted = await _save_health_check_results_if_changed(
|
||||
mock_prisma, _two_model_results(), {}, 1234567890.0, "background_health_check"
|
||||
)
|
||||
|
||||
assert (persisted, mock_prisma.save_health_check_result.await_count) == (False, 2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_health_check_results_if_changed_reports_success_when_nothing_needed_writing():
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.save_health_check_result = AsyncMock()
|
||||
model_results = {
|
||||
("model-1", "gpt-4"): {
|
||||
"model_name": "gpt-4",
|
||||
"model_id": "model-1",
|
||||
"healthy_count": 1,
|
||||
"unhealthy_count": 0,
|
||||
"error_message": None,
|
||||
},
|
||||
}
|
||||
latest_checks_map = {
|
||||
"model-1": MagicMock(status="healthy", checked_at=datetime.now(timezone.utc) - timedelta(minutes=5)),
|
||||
}
|
||||
|
||||
persisted = await _save_health_check_results_if_changed(
|
||||
mock_prisma, model_results, latest_checks_map, 1234567890.0, "background_health_check"
|
||||
)
|
||||
|
||||
assert (persisted, mock_prisma.save_health_check_result.await_count) == (True, 0)
|
||||
|
||||
|
||||
def _one_model_setup():
|
||||
model_list = [
|
||||
{
|
||||
"model_name": "gpt-3.5-turbo",
|
||||
"model_info": {"id": "model-123"},
|
||||
"litellm_params": {"model": "gpt-3.5-turbo"},
|
||||
},
|
||||
]
|
||||
return model_list, [{"model": "gpt-3.5-turbo"}], []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_background_health_checks_to_db_returns_false_when_a_write_fails():
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[])
|
||||
mock_prisma.save_health_check_result = AsyncMock(return_value=None)
|
||||
model_list, healthy_endpoints, unhealthy_endpoints = _one_model_setup()
|
||||
|
||||
persisted = await _save_background_health_checks_to_db(
|
||||
mock_prisma, model_list, healthy_endpoints, unhealthy_endpoints, 1234567890.0, "background_health_check"
|
||||
)
|
||||
|
||||
assert (persisted, mock_prisma.save_health_check_result.await_count) == (False, 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_background_health_checks_to_db_no_prisma():
|
||||
"""Test graceful handling when no prisma client"""
|
||||
result = await _save_background_health_checks_to_db(
|
||||
None, [], [], [], 0.0, "background_health_check"
|
||||
)
|
||||
assert result is None
|
||||
result = await _save_background_health_checks_to_db(None, [], [], [], 0.0, "background_health_check")
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_background_health_checks_to_db_exception_handling():
|
||||
"""Test exception handling in background health check save"""
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.get_all_latest_health_checks = AsyncMock(
|
||||
side_effect=Exception("DB Error")
|
||||
)
|
||||
mock_prisma.get_all_latest_health_checks = AsyncMock(side_effect=Exception("DB Error"))
|
||||
|
||||
model_list = [
|
||||
{
|
||||
|
|
@ -446,12 +525,13 @@ async def test_save_background_health_checks_to_db_exception_handling():
|
|||
},
|
||||
]
|
||||
|
||||
# Should not raise exception, should handle gracefully
|
||||
await _save_background_health_checks_to_db(
|
||||
# Must not raise (the health check loop has to survive a DB outage) but must report
|
||||
# the failure, so the window lock can be released for another pod to retry
|
||||
persisted = await _save_background_health_checks_to_db(
|
||||
mock_prisma, model_list, [], [], 0.0, "background_health_check"
|
||||
)
|
||||
|
||||
# Function should complete without raising
|
||||
assert persisted is False
|
||||
|
||||
|
||||
def _raw_latest_row(model_name: str, model_id, checked_at: datetime) -> dict:
|
||||
|
|
@ -660,12 +740,7 @@ def test_parse_background_health_check_model_groups_unset_returns_none():
|
|||
|
||||
assert parse_background_health_check_model_groups(None) is None
|
||||
assert parse_background_health_check_model_groups({}) is None
|
||||
assert (
|
||||
parse_background_health_check_model_groups(
|
||||
{"background_health_check_model_groups": None}
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert parse_background_health_check_model_groups({"background_health_check_model_groups": None}) is None
|
||||
|
||||
|
||||
def test_parse_background_health_check_model_groups_list_returns_frozenset():
|
||||
|
|
@ -682,9 +757,7 @@ def test_parse_background_health_check_model_groups_malformed_raises(bad_value):
|
|||
from litellm.proxy.health_check import parse_background_health_check_model_groups
|
||||
|
||||
with pytest.raises(ValueError, match="must be a list of model group names"):
|
||||
parse_background_health_check_model_groups(
|
||||
{"background_health_check_model_groups": bad_value}
|
||||
)
|
||||
parse_background_health_check_model_groups({"background_health_check_model_groups": bad_value})
|
||||
|
||||
|
||||
def test_filter_deployments_to_model_groups():
|
||||
|
|
@ -697,9 +770,7 @@ def test_filter_deployments_to_model_groups():
|
|||
]
|
||||
|
||||
assert filter_deployments_to_model_groups(model_list, None) == tuple(model_list)
|
||||
assert filter_deployments_to_model_groups(
|
||||
model_list, frozenset({"prod-openai"})
|
||||
) == (model_list[0], model_list[2])
|
||||
assert filter_deployments_to_model_groups(model_list, frozenset({"prod-openai"})) == (model_list[0], model_list[2])
|
||||
assert filter_deployments_to_model_groups(model_list, frozenset()) == ()
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue