fix(proxy): prevent HealthCheckTable unbounded growth

LiteLLM_HealthCheckTable grows without bound when external monitoring
tools (e.g. Uptime Kuma) ping health endpoints continuously.  With
500 k+ rows the dashboard query loads every row into Python memory,
causing 8 GB+ container memory usage and 400% CPU spikes.

Two changes fix this:

1. get_all_latest_health_checks: add a checked_at >= (now - TTL)
   WHERE filter so only recent rows are fetched.  Previously the
   method did a full-table find_many with no bounds and deduplicated
   in Python.  With the filter the DB does the work and Python only
   sees O(models) rows.

2. cleanup_old_health_checks: new method that deletes rows older than
   TTL via a single delete_many call.  save_health_check_result calls
   it at most once per hour (tracked via _health_check_last_cleanup_ts)
   to keep the table bounded without adding per-insert overhead.

TTL defaults to 7 days and is configurable via HEALTH_CHECK_TTL_DAYS.

Fixes #25623
This commit is contained in:
Claude 2026-04-13 20:01:50 +00:00
parent d319cd8cc6
commit 0009995729
2 changed files with 324 additions and 115 deletions

View file

@ -1965,9 +1965,9 @@ class ProxyLogging:
normalized_call_type = CallTypes.aembedding.value
if normalized_call_type is not None:
litellm_logging_obj.call_type = normalized_call_type
litellm_logging_obj.model_call_details[
"call_type"
] = normalized_call_type
litellm_logging_obj.model_call_details["call_type"] = (
normalized_call_type
)
# Pass-through endpoints are logged via the callback loop's
# async_post_call_failure_hook — skip pre_call and failure handlers.
if litellm_logging_obj.call_type == CallTypes.pass_through.value:
@ -2065,10 +2065,12 @@ class ProxyLogging:
raise
else:
try:
guardrail_response = await callback.async_post_call_success_hook(
user_api_key_dict=user_api_key_dict,
data=data,
response=response,
guardrail_response = (
await callback.async_post_call_success_hook(
user_api_key_dict=user_api_key_dict,
data=data,
response=response,
)
)
except Exception as e:
_enrich_http_exception_with_guardrail_context(e, callback)
@ -2281,13 +2283,15 @@ class ProxyLogging:
"async_post_call_streaming_iterator_hook"
in type(callback).__dict__
):
current_response = self._wrap_streaming_iterator_with_enrichment(
_callback,
_callback.async_post_call_streaming_iterator_hook(
user_api_key_dict=user_api_key_dict,
response=current_response,
request_data=request_data,
),
current_response = (
self._wrap_streaming_iterator_with_enrichment(
_callback,
_callback.async_post_call_streaming_iterator_hook(
user_api_key_dict=user_api_key_dict,
response=current_response,
request_data=request_data,
),
)
)
elif "apply_guardrail" in type(callback).__dict__:
request_data["guardrail_to_apply"] = callback
@ -2300,13 +2304,15 @@ class ProxyLogging:
),
)
else:
current_response = self._wrap_streaming_iterator_with_enrichment(
_callback,
_callback.async_post_call_streaming_iterator_hook(
user_api_key_dict=user_api_key_dict,
response=current_response,
request_data=request_data,
),
current_response = (
self._wrap_streaming_iterator_with_enrichment(
_callback,
_callback.async_post_call_streaming_iterator_hook(
user_api_key_dict=user_api_key_dict,
response=current_response,
request_data=request_data,
),
)
)
# Actually iterate through the chained async generator and yield chunks
@ -2512,6 +2518,7 @@ class PrismaClient:
self._watching_engine: bool = False
self._engine_confirmed_dead: bool = False
self._engine_wait_thread: Optional[threading.Thread] = None
self._health_check_last_cleanup_ts: float = 0.0
verbose_proxy_logger.debug("Success - Created Prisma Client")
def get_request_status(
@ -4487,7 +4494,17 @@ class PrismaClient:
)
verbose_proxy_logger.debug(f"Saving health check data: {health_check_data}")
return await self.db.litellm_healthchecktable.create(data=health_check_data)
result = await self.db.litellm_healthchecktable.create(
data=health_check_data
)
# Prune old rows at most once per hour to prevent unbounded table growth
now = time.time()
if now - self._health_check_last_cleanup_ts >= 3600:
self._health_check_last_cleanup_ts = now
await self.cleanup_old_health_checks()
return result
except Exception as e:
verbose_proxy_logger.error(
@ -4525,25 +4542,29 @@ class PrismaClient:
async def get_all_latest_health_checks(self):
"""
Get the latest health check for each model
Get the latest health check for each model.
Only considers rows within the retention window (HEALTH_CHECK_TTL_DAYS) to
avoid full-table scans on deployments with large health check histories.
"""
try:
# Get all unique model names first
health_check_ttl_days = int(os.getenv("HEALTH_CHECK_TTL_DAYS", "7"))
cutoff = datetime.utcnow() - timedelta(days=health_check_ttl_days)
all_checks = await self.db.litellm_healthchecktable.find_many(
order={"checked_at": "desc"}
where={"checked_at": {"gte": cutoff}},
order={"checked_at": "desc"},
)
# Group by model_name and get the latest for each
# Group by model and return only the latest entry per model
latest_checks = {}
for check in all_checks:
# Create a unique key: prefer model_id if available, otherwise use model_name
# This ensures we get the latest check for each unique model
# Prefer model_id as the dedup key; fall back to model_name
if check.model_id:
key = (check.model_id, check.model_name)
else:
key = (None, check.model_name)
# Only add if we haven't seen this key yet (since checks are ordered by checked_at desc)
# First entry wins because rows are ordered by checked_at desc
if key not in latest_checks:
latest_checks[key] = check
@ -4552,6 +4573,25 @@ class PrismaClient:
verbose_proxy_logger.error(f"Error getting all latest health checks: {e}")
return []
async def cleanup_old_health_checks(self) -> None:
"""
Delete LiteLLM_HealthCheckTable rows older than HEALTH_CHECK_TTL_DAYS (default 7).
Called from save_health_check_result at most once per hour to prevent
unbounded table growth without adding per-insert overhead.
"""
try:
health_check_ttl_days = int(os.getenv("HEALTH_CHECK_TTL_DAYS", "7"))
cutoff = datetime.utcnow() - timedelta(days=health_check_ttl_days)
result = await self.db.litellm_healthchecktable.delete_many(
where={"checked_at": {"lt": cutoff}}
)
verbose_proxy_logger.debug(
f"Health check cleanup: deleted {result} rows older than {health_check_ttl_days} days"
)
except Exception as e:
verbose_proxy_logger.error(f"Error cleaning up old health checks: {e}")
### HELPER FUNCTIONS ###
@ -4947,25 +4987,27 @@ async def update_daily_tag_spend(
):
"""
Separate scheduler job to commit daily tag spend updates.
Runs at a longer interval (2.3x default) than the main update_spend job
to reduce query contention for DailyTagSpend table.
This is called by a dedicated scheduler job and does NOT process:
- Regular spend updates (user, key, team, org)
- End-user spend
- Agent spend
- Spend logs
Only processes tag spend transactions from the daily_tag_spend_update_queue.
Args:
prisma_client: PrismaClient instance
proxy_logging_obj: ProxyLogging instance for error handling
"""
n_retry_times = 3
try:
if proxy_logging_obj.db_spend_update_writer.redis_update_buffer._should_commit_spend_updates_to_redis():
if (
proxy_logging_obj.db_spend_update_writer.redis_update_buffer._should_commit_spend_updates_to_redis()
):
await proxy_logging_obj.db_spend_update_writer._commit_daily_tag_spend_to_db_with_redis(
prisma_client=prisma_client,
n_retry_times=n_retry_times,

View file

@ -24,33 +24,53 @@ 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
for method in ['save_health_check_result', '_validate_response_time', '_clean_details',
'get_health_check_history', 'get_all_latest_health_checks']:
for method in [
"save_health_check_result",
"_validate_response_time",
"_clean_details",
"get_health_check_history",
"get_all_latest_health_checks",
]:
setattr(client, method, types.MethodType(getattr(PrismaClient, method), client))
return client
@pytest.mark.asyncio
@pytest.mark.parametrize("status,healthy,unhealthy,should_succeed", [
("healthy", 1, 0, True),
("unhealthy", 0, 1, True),
("healthy", 1, 0, False), # Database error case
])
async def test_save_health_check_result(mock_prisma, status, healthy, unhealthy, should_succeed):
@pytest.mark.parametrize(
"status,healthy,unhealthy,should_succeed",
[
("healthy", 1, 0, True),
("unhealthy", 0, 1, True),
("healthy", 1, 0, False), # Database error case
],
)
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", status=status, healthy_count=healthy, unhealthy_count=unhealthy
model_name="test-model",
status=status,
healthy_count=healthy,
unhealthy_count=unhealthy,
)
if should_succeed:
mock_prisma.db.litellm_healthchecktable.create.assert_called_once()
else:
@ -66,24 +86,31 @@ async def test_get_health_check_history(mock_prisma):
@pytest.mark.asyncio
@pytest.mark.parametrize("healthy_count,unhealthy_count,expected_status", [
(1, 0, "healthy"),
(0, 1, "unhealthy"),
(2, 1, "healthy"),
])
@pytest.mark.parametrize(
"healthy_count,unhealthy_count,expected_status",
[
(1, 0, "healthy"),
(0, 1, "unhealthy"),
(2, 1, "healthy"),
],
)
async def test_save_health_check_to_db(healthy_count, unhealthy_count, expected_status):
"""Test _save_health_check_to_db function with different endpoint counts"""
mock_client = MagicMock()
mock_client.save_health_check_result = AsyncMock()
healthy_endpoints = [{"model": "test"}] * healthy_count
unhealthy_endpoints = [{"error": "test error"}] * unhealthy_count
await _save_health_check_to_db(
mock_client, "test-model", healthy_endpoints, unhealthy_endpoints,
1234567890.0, "test-user"
mock_client,
"test-model",
healthy_endpoints,
unhealthy_endpoints,
1234567890.0,
"test-user",
)
call_args = mock_client.save_health_check_result.call_args[1]
assert call_args["status"] == expected_status
assert call_args["healthy_count"] == healthy_count
@ -99,6 +126,7 @@ async def test_save_health_check_to_db_no_client():
# Tests for background health check functions
def test_build_model_param_to_info_mapping():
"""Test building model parameter to info mapping"""
model_list = [
@ -118,9 +146,9 @@ def test_build_model_param_to_info_mapping():
"litellm_params": {"model": "gpt-3.5-turbo"}, # Same model param
},
]
result = _build_model_param_to_info_mapping(model_list)
assert "gpt-3.5-turbo" in result
assert "gpt-4" in result
assert len(result["gpt-3.5-turbo"]) == 2 # Two models share same param
@ -139,7 +167,7 @@ def test_build_model_param_to_info_mapping_no_model_name():
"litellm_params": {"model": "gpt-3.5-turbo"},
},
]
result = _build_model_param_to_info_mapping(model_list)
assert len(result) == 0
@ -154,25 +182,25 @@ def test_aggregate_health_check_results():
{"model_name": "gpt-4", "model_id": "model-456"},
],
}
healthy_endpoints = [
{"model": "gpt-3.5-turbo"},
]
unhealthy_endpoints = [
{"model": "gpt-4", "error": "Rate limit exceeded"},
]
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")
assert gpt35_key in result
assert result[gpt35_key]["healthy_count"] == 1
assert result[gpt35_key]["unhealthy_count"] == 0
assert result[gpt35_key]["error_message"] is None
# Check gpt-4 is unhealthy
gpt4_key = ("model-456", "gpt-4")
assert gpt4_key in result
@ -188,17 +216,17 @@ def test_aggregate_health_check_results_multiple_endpoints():
{"model_name": "gpt-3.5-turbo", "model_id": "model-123"},
],
}
healthy_endpoints = [
{"model": "gpt-3.5-turbo"},
{"model": "gpt-3.5-turbo"},
]
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
assert result[key]["unhealthy_count"] == 0
@ -209,7 +237,7 @@ async def test_save_health_check_results_if_changed_status_changed():
"""Test saving when status changes"""
mock_prisma = MagicMock()
mock_prisma.save_health_check_result = AsyncMock()
model_results = {
("model-123", "gpt-3.5-turbo"): {
"model_name": "gpt-3.5-turbo",
@ -219,7 +247,7 @@ async def test_save_health_check_results_if_changed_status_changed():
"error_message": None,
},
}
# Latest check shows unhealthy, new result is healthy (status changed)
latest_checks_map = {
"model-123": MagicMock(
@ -227,12 +255,16 @@ async def test_save_health_check_results_if_changed_status_changed():
checked_at=datetime.now(timezone.utc) - timedelta(minutes=5),
),
}
start_time = 1234567890.0
await _save_health_check_results_if_changed(
mock_prisma, model_results, latest_checks_map, start_time, "background_health_check"
mock_prisma,
model_results,
latest_checks_map,
start_time,
"background_health_check",
)
# Should save because status changed
mock_prisma.save_health_check_result.assert_called_once()
call_kwargs = mock_prisma.save_health_check_result.call_args[1]
@ -246,7 +278,7 @@ async def test_save_health_check_results_if_changed_status_unchanged_recent():
"""Test skipping save when status unchanged and checked recently"""
mock_prisma = MagicMock()
mock_prisma.save_health_check_result = AsyncMock()
model_results = {
("model-123", "gpt-3.5-turbo"): {
"model_name": "gpt-3.5-turbo",
@ -256,7 +288,7 @@ async def test_save_health_check_results_if_changed_status_unchanged_recent():
"error_message": None,
},
}
# Latest check shows healthy, new result is healthy (status unchanged)
# And checked recently (within 1 hour)
latest_checks_map = {
@ -265,12 +297,16 @@ async def test_save_health_check_results_if_changed_status_unchanged_recent():
checked_at=datetime.now(timezone.utc) - timedelta(minutes=30),
),
}
start_time = 1234567890.0
await _save_health_check_results_if_changed(
mock_prisma, model_results, latest_checks_map, start_time, "background_health_check"
mock_prisma,
model_results,
latest_checks_map,
start_time,
"background_health_check",
)
# Should NOT save because status unchanged and checked recently
mock_prisma.save_health_check_result.assert_not_called()
@ -280,7 +316,7 @@ async def test_save_health_check_results_if_changed_status_unchanged_old():
"""Test saving when status unchanged but last check is old (>1 hour)"""
mock_prisma = MagicMock()
mock_prisma.save_health_check_result = AsyncMock()
model_results = {
("model-123", "gpt-3.5-turbo"): {
"model_name": "gpt-3.5-turbo",
@ -290,7 +326,7 @@ async def test_save_health_check_results_if_changed_status_unchanged_old():
"error_message": None,
},
}
# Latest check shows healthy, new result is healthy (status unchanged)
# But checked >1 hour ago
latest_checks_map = {
@ -299,12 +335,16 @@ async def test_save_health_check_results_if_changed_status_unchanged_old():
checked_at=datetime.now(timezone.utc) - timedelta(hours=2),
),
}
start_time = 1234567890.0
await _save_health_check_results_if_changed(
mock_prisma, model_results, latest_checks_map, start_time, "background_health_check"
mock_prisma,
model_results,
latest_checks_map,
start_time,
"background_health_check",
)
# Should save because last check is old (>1 hour)
mock_prisma.save_health_check_result.assert_called_once()
@ -314,7 +354,7 @@ async def test_save_health_check_results_if_changed_no_previous_check():
"""Test saving when there's no previous check"""
mock_prisma = MagicMock()
mock_prisma.save_health_check_result = AsyncMock()
model_results = {
("model-123", "gpt-3.5-turbo"): {
"model_name": "gpt-3.5-turbo",
@ -324,15 +364,19 @@ async def test_save_health_check_results_if_changed_no_previous_check():
"error_message": None,
},
}
# No previous check
latest_checks_map = {}
start_time = 1234567890.0
await _save_health_check_results_if_changed(
mock_prisma, model_results, latest_checks_map, start_time, "background_health_check"
mock_prisma,
model_results,
latest_checks_map,
start_time,
"background_health_check",
)
# Should save because no previous check
mock_prisma.save_health_check_result.assert_called_once()
@ -343,7 +387,7 @@ async def test_save_background_health_checks_to_db():
mock_prisma = MagicMock()
mock_prisma.save_health_check_result = AsyncMock()
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[])
model_list = [
{
"model_name": "gpt-3.5-turbo",
@ -351,20 +395,25 @@ async def test_save_background_health_checks_to_db():
"litellm_params": {"model": "gpt-3.5-turbo"},
},
]
healthy_endpoints = [{"model": "gpt-3.5-turbo"}]
unhealthy_endpoints = []
start_time = 1234567890.0
await _save_background_health_checks_to_db(
mock_prisma, model_list, healthy_endpoints, unhealthy_endpoints, start_time, "background_health_check"
mock_prisma,
model_list,
healthy_endpoints,
unhealthy_endpoints,
start_time,
"background_health_check",
)
# Should call get_all_latest_health_checks and save_health_check_result
mock_prisma.get_all_latest_health_checks.assert_called_once()
mock_prisma.save_health_check_result.assert_called_once()
call_kwargs = mock_prisma.save_health_check_result.call_args[1]
assert call_kwargs["model_name"] == "gpt-3.5-turbo"
assert call_kwargs["model_id"] == "model-123"
@ -385,8 +434,10 @@ async def test_save_background_health_checks_to_db_no_prisma():
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 = [
{
"model_name": "gpt-3.5-turbo",
@ -394,12 +445,12 @@ async def test_save_background_health_checks_to_db_exception_handling():
"litellm_params": {"model": "gpt-3.5-turbo"},
},
]
# Should not raise exception, should handle gracefully
await _save_background_health_checks_to_db(
mock_prisma, model_list, [], [], 0.0, "background_health_check"
)
# Function should complete without raising
@ -411,32 +462,34 @@ async def test_get_all_latest_health_checks_with_model_id(mock_prisma):
mock_check1.model_id = "model-123"
mock_check1.model_name = "gpt-3.5-turbo"
mock_check1.checked_at = datetime.now(timezone.utc) - timedelta(minutes=10)
mock_check2 = MagicMock()
mock_check2.model_id = "model-456"
mock_check2.model_name = "gpt-3.5-turbo"
mock_check2.checked_at = datetime.now(timezone.utc) - timedelta(minutes=5)
mock_check3 = MagicMock()
mock_check3.model_id = "model-123"
mock_check3.model_name = "gpt-3.5-turbo"
mock_check3.checked_at = datetime.now(timezone.utc) - timedelta(minutes=1) # Latest for model-123
mock_check3.checked_at = datetime.now(timezone.utc) - timedelta(
minutes=1
) # Latest for model-123
# Order by checked_at desc
mock_prisma.db.litellm_healthchecktable.find_many = AsyncMock(
return_value=[mock_check3, mock_check2, mock_check1]
)
result = await mock_prisma.get_all_latest_health_checks()
# Should return 2 unique models (by model_id)
assert len(result) == 2
# Should have latest check for each model_id
model_ids = {check.model_id for check in result}
assert "model-123" in model_ids
assert "model-456" in model_ids
# model-123 should have the latest check (1 minute ago)
model123_check = next(c for c in result if c.model_id == "model-123")
assert model123_check.checked_at == mock_check3.checked_at
@ -449,18 +502,18 @@ async def test_get_all_latest_health_checks_without_model_id(mock_prisma):
mock_check1.model_id = None
mock_check1.model_name = "gpt-3.5-turbo"
mock_check1.checked_at = datetime.now(timezone.utc) - timedelta(minutes=10)
mock_check2 = MagicMock()
mock_check2.model_id = None
mock_check2.model_name = "gpt-3.5-turbo"
mock_check2.checked_at = datetime.now(timezone.utc) - timedelta(minutes=1) # Latest
mock_prisma.db.litellm_healthchecktable.find_many = AsyncMock(
return_value=[mock_check2, mock_check1]
)
result = await mock_prisma.get_all_latest_health_checks()
# Should return 1 unique model (by model_name)
assert len(result) == 1
assert result[0].model_name == "gpt-3.5-turbo"
@ -480,7 +533,14 @@ async def test_perform_health_check_and_save_passes_model_id_to_perform_health_c
healthy = [{"model": "gpt-4"}]
unhealthy = []
async def mock_perform_health_check(model_list, model=None, cli_model=None, details=True, model_id=None, max_concurrency=None):
async def mock_perform_health_check(
model_list,
model=None,
cli_model=None,
details=True,
model_id=None,
max_concurrency=None,
):
return healthy, unhealthy, {}
with patch(
@ -505,5 +565,112 @@ async def test_perform_health_check_and_save_passes_model_id_to_perform_health_c
assert result["unhealthy_count"] == 0
@pytest.mark.asyncio
async def test_get_all_latest_health_checks_applies_ttl_filter(mock_prisma):
"""get_all_latest_health_checks should pass a checked_at >= cutoff filter so
it does not load the entire table into memory."""
mock_prisma.db.litellm_healthchecktable.find_many = AsyncMock(return_value=[])
await mock_prisma.get_all_latest_health_checks()
call_kwargs = mock_prisma.db.litellm_healthchecktable.find_many.call_args[1]
assert "where" in call_kwargs
assert "checked_at" in call_kwargs["where"]
assert "gte" in call_kwargs["where"]["checked_at"]
@pytest.mark.asyncio
async def test_get_all_latest_health_checks_ttl_env_override(mock_prisma):
"""HEALTH_CHECK_TTL_DAYS env var should control the cutoff window."""
mock_prisma.db.litellm_healthchecktable.find_many = AsyncMock(return_value=[])
with patch.dict(os.environ, {"HEALTH_CHECK_TTL_DAYS": "3"}):
await mock_prisma.get_all_latest_health_checks()
call_kwargs = mock_prisma.db.litellm_healthchecktable.find_many.call_args[1]
cutoff = call_kwargs["where"]["checked_at"]["gte"]
# The cutoff should be roughly 3 days ago (within a small tolerance)
expected = datetime.utcnow() - timedelta(days=3)
assert abs((expected - cutoff).total_seconds()) < 5
@pytest.mark.asyncio
async def test_cleanup_old_health_checks_calls_delete_many(mock_prisma):
"""cleanup_old_health_checks should delete rows older than TTL via delete_many."""
import types
mock_prisma.db.litellm_healthchecktable.delete_many = AsyncMock(return_value=42)
mock_prisma.cleanup_old_health_checks = types.MethodType(
PrismaClient.cleanup_old_health_checks, mock_prisma
)
await mock_prisma.cleanup_old_health_checks()
call_kwargs = mock_prisma.db.litellm_healthchecktable.delete_many.call_args[1]
assert "where" in call_kwargs
assert "checked_at" in call_kwargs["where"]
assert "lt" in call_kwargs["where"]["checked_at"]
@pytest.mark.asyncio
async def test_save_health_check_result_triggers_cleanup_after_one_hour():
"""cleanup_old_health_checks should be called when last cleanup was > 1 hour ago."""
import types
mock_client = MagicMock()
mock_client.db.litellm_healthchecktable.create = AsyncMock(return_value={"id": "x"})
mock_client.db.litellm_healthchecktable.delete_many = AsyncMock(return_value=0)
mock_client._health_check_last_cleanup_ts = 0.0 # never cleaned up
# Bind helper methods properly so `self` is resolved correctly
mock_client._validate_response_time = types.MethodType(
PrismaClient._validate_response_time, mock_client
)
mock_client._clean_details = types.MethodType(
PrismaClient._clean_details, mock_client
)
mock_client.save_health_check_result = types.MethodType(
PrismaClient.save_health_check_result, mock_client
)
mock_client.cleanup_old_health_checks = types.MethodType(
PrismaClient.cleanup_old_health_checks, mock_client
)
await mock_client.save_health_check_result(
model_name="gpt-4", status="healthy", healthy_count=1, unhealthy_count=0
)
mock_client.db.litellm_healthchecktable.delete_many.assert_called_once()
@pytest.mark.asyncio
async def test_save_health_check_result_skips_cleanup_within_one_hour():
"""cleanup_old_health_checks should NOT be called if last cleanup was < 1 hour ago."""
import types
mock_client = MagicMock()
mock_client.db.litellm_healthchecktable.create = AsyncMock(return_value={"id": "x"})
mock_client.db.litellm_healthchecktable.delete_many = AsyncMock(return_value=0)
mock_client._health_check_last_cleanup_ts = time.time() # just ran
# Bind helper methods properly so `self` is resolved correctly
mock_client._validate_response_time = types.MethodType(
PrismaClient._validate_response_time, mock_client
)
mock_client._clean_details = types.MethodType(
PrismaClient._clean_details, mock_client
)
mock_client.save_health_check_result = types.MethodType(
PrismaClient.save_health_check_result, mock_client
)
mock_client.cleanup_old_health_checks = types.MethodType(
PrismaClient.cleanup_old_health_checks, mock_client
)
await mock_client.save_health_check_result(
model_name="gpt-4", status="healthy", healthy_count=1, unhealthy_count=0
)
mock_client.db.litellm_healthchecktable.delete_many.assert_not_called()
if __name__ == "__main__":
pytest.main([__file__])
pytest.main([__file__])