Files
litellm/tests/test_litellm/proxy/test_shared_health_check.py
T
51876292a0 Litellm ishaan april4 2 (#25150)
* feat(router): integrate allowed_fails_policy into health check failures (#24988)

* feat(router): integrate allowed_fails_policy into health check failures

Health check failures now increment the same per-deployment failure
counters used by allowed_fails_policy, so users can control how many
health check failures of each error type are required before a
deployment enters cooldown.

- ahealth_check() preserves the original exception in its return dict
- run_with_timeout() returns a litellm.Timeout on health check timeout
- _perform_health_check() propagates exceptions to unhealthy endpoints
- _write_health_state_to_router_cache() calls _set_cooldown_deployments
  for each unhealthy endpoint that has an exception
- When allowed_fails_policy is set, the binary health check filter is
  bypassed so cooldown is the sole routing exclusion mechanism
- Safety net: if all deployments are in cooldown with
  enable_health_check_routing=True, the cooldown filter is bypassed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(router): add health_check_ignore_transient_errors flag

When enabled, health check failures with 429 (rate limit) or 408 (timeout)
status codes are skipped from the cooldown pipeline. These are transient
load issues, not broken deployments. Auth errors (401), 404, and 5xx errors
still increment counters and trigger cooldown as before.

Config (general_settings):
  health_check_ignore_transient_errors: true

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(router): also exclude 429/408 from health state cache when ignore_transient_errors set

The previous fix only skipped cooldown counter increments. The health state
cache was still marking 429/408 endpoints as is_healthy=False, causing the
binary health check filter to exclude them from routing.

Now, when health_check_ignore_transient_errors=True, 429/408 endpoints are
also excluded from the unhealthy list passed to build_deployment_health_states(),
so the binary filter treats them as unaffected (not unhealthy).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(router): add health check driven routing guide

New standalone page covering the full health check routing feature:
allowed_fails_policy integration, health_check_ignore_transient_errors,
architecture SVG, step-by-step setup, and gotchas (TTL, AllowedFails semantics).

Replaces the inline section in health.md with a link to the new page.
Added to the Routing & Load Balancing sidebar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(health-check-routing): fix three CI failures

- Add "exception" to ILLEGAL_DISPLAY_PARAMS in health_check.py so the
  exception object is stripped before the health endpoint serializes
  results to JSON (fixes TypeError: 'URL' object is not iterable)
- Add allowed_fails_policy = None to FakeRouter stubs in
  test_router_health_check_routing.py (fixes AttributeError)
- Add health_check_ignore_transient_errors to config_settings.md router
  settings reference table (fixes documentation test)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix litellm/tests/proxy_unit_tests/test_proxy_server.py

* fix(router): address greptile review comments

- Narrow cooldown safety-net bypass: only fires when allowed_fails_policy
  is set (cooldown is health-check driven). Without a policy, cooldowns
  are from real request failures and must not be bypassed.
- Restore cooldown deployments DEBUG log that was accidentally removed.
- Fix test_health TypeError: move exception extraction to a separate
  exceptions_by_model_id dict returned alongside endpoints, so exception
  objects never appear in the endpoint dicts that get JSON-serialized
  by the /health response.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(health-check-routing): properly isolate exceptions from health response

Return exceptions_by_model_id as a separate third value from
_perform_health_check / perform_health_check so exception objects
(which contain non-JSON-serializable httpx URL types) never appear
in the endpoint dicts that get serialized by the /health response.

Callers updated: _health_endpoints.py, shared_health_check_manager.py,
proxy_server.py background loop. All use the exceptions dict only for
cooldown integration, not for display.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(shared-health-check): fix remaining 2-value return sites and update type annotation

* fix(health-check-routing): fix P0 cooldown integration never firing

The cooldown loop was reading endpoint.get("exception") which is always
None because exceptions are now returned via exceptions_by_model_id, not
stored in endpoint dicts. Fixed to use _exceptions.get(model_id).

Also fixes the transient-error filter to use _exceptions instead of
endpoint.get("exception"), and fixes all remaining 2-value return sites
in shared_health_check_manager.py. Tests updated to pass exceptions via
exceptions_by_model_id parameter instead of endpoint dicts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(health-check-routing): fix P1 transient-error filter broken on cache hits

When SharedHealthCheckManager returns cached results, exceptions_by_model_id
is always {} so the transient-error filter defaulted to status 500 for all
endpoints, incorrectly marking 429/408 endpoints as unhealthy.

Fix: store integer exception_status on each unhealthy endpoint dict in
_perform_health_check. _get_endpoint_exception_status() uses the live
exception object when available (direct path) and falls back to the stored
integer (cache-hit path). The integer is JSON-serializable and survives
the shared cache round-trip.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(health-check-routing): gate cooldown loop behind allowed_fails_policy

Without the policy, cooldown is not the routing exclusion mechanism.
Firing _set_cooldown_deployments for all enable_health_check_routing users
was a backwards-incompatible change — 401s would immediately cooldown
deployments that the binary filter would have recovered on the next cycle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* revert: undo allowed_fails_policy gate on cooldown loop

Cooldown integration via health checks is intentional for all
enable_health_check_routing users, not just those with allowed_fails_policy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(docs+tests): fix health_check_ignore_transient_errors doc section and test coverage

- Move health_check_ignore_transient_errors from router_settings to
  general_settings in config_settings.md (code reads it from general_settings)
- Remove duplicate enable_health_check_routing / health_check_staleness_threshold
  entries that were incorrectly listed under router_settings
- Replace TestHealthCheckEndpointExceptionPropagation tests with ones that
  exercise the real _perform_health_check code path via mocked ahealth_check,
  verifying exceptions appear in exceptions_by_model_id and NOT in endpoint dicts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(tests+docs): fix tuple unpacking and docs test failures

- Update test mocks that return (healthy, unhealthy) to return
  (healthy, unhealthy, {}) to match the new 3-value signature
- Update test unpackings of perform_shared_health_check to use
  healthy, unhealthy, _ = ...
- Add health_check_ignore_transient_errors to router_settings section
  in config_settings.md (it is a Router constructor param, so the doc
  test requires it there; it also lives in general_settings for proxy use)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix CodeQL errors

* fix(tests): fix 2-value unpackings of _perform_health_check in test_health_check.py

* fix(tests): fix mock _perform_health_check returning 2-tuple instead of 3

* fix team routing

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add distributed lock for key rotation job (#23364)

* fix: add distributed lock for key rotation job

* fix: address Greptile review feedback on key rotation lock (#23834)

* fix: address Greptile review feedback on key rotation lock

* fix req changes greptile

* feat(proxy): Optional on_error for guardrail pipeline (API / technical failures) (#24831)

* guardrails fallback

* docs

* docs: add LITELLM_KEY_ROTATION_LOCK_TTL_SECONDS to environment variables reference

* fix(mypy): accept Union[Dict, Any] in _get_deployment_order and use typed list to fix min() type error

* fix(mypy): use Optional[str] for api_base in PydanticAI provider to match superclass signature

---------

Co-authored-by: Sameer Kankute <sameer@berri.ai>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Harshit Jain <48647625+Harshit28j@users.noreply.github.com>
Co-authored-by: Shivam Rawat <shivam@berri.ai>
Co-authored-by: yuneng-jiang <yuneng@berri.ai>
2026-04-04 23:09:42 +00:00

407 lines
17 KiB
Python

import asyncio
import json
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from litellm.proxy.health_check_utils.shared_health_check_manager import (
SharedHealthCheckManager,
)
class TestSharedHealthCheckManager:
"""Test cases for SharedHealthCheckManager"""
@pytest.fixture
def mock_redis_cache(self):
"""Mock Redis cache for testing"""
cache = AsyncMock()
cache.async_set_cache = AsyncMock()
cache.async_get_cache = AsyncMock()
cache.async_delete_cache = AsyncMock()
return cache
@pytest.fixture
def shared_health_manager(self, mock_redis_cache):
"""Create SharedHealthCheckManager instance for testing"""
return SharedHealthCheckManager(
redis_cache=mock_redis_cache,
health_check_ttl=300,
lock_ttl=60,
)
def test_initialization(self, mock_redis_cache):
"""Test SharedHealthCheckManager initialization"""
manager = SharedHealthCheckManager(
redis_cache=mock_redis_cache,
health_check_ttl=300,
lock_ttl=60,
)
assert manager.redis_cache == mock_redis_cache
assert manager.health_check_ttl == 300
assert manager.lock_ttl == 60
assert manager.pod_id.startswith("pod_")
def test_initialization_without_redis(self):
"""Test SharedHealthCheckManager initialization without Redis"""
manager = SharedHealthCheckManager(redis_cache=None)
assert manager.redis_cache is None
assert manager.health_check_ttl == 300 # Default value
assert manager.lock_ttl == 60 # Default value
def test_get_health_check_lock_key(self):
"""Test getting health check lock key"""
key = SharedHealthCheckManager.get_health_check_lock_key()
assert key == "health_check_lock"
def test_get_health_check_cache_key(self):
"""Test getting health check cache key"""
key = SharedHealthCheckManager.get_health_check_cache_key()
assert key == "health_check_results"
def test_get_model_health_check_lock_key(self):
"""Test getting model-specific health check lock key"""
key = SharedHealthCheckManager.get_model_health_check_lock_key("test-model")
assert key == "health_check_lock:test-model"
def test_get_model_health_check_cache_key(self):
"""Test getting model-specific health check cache key"""
key = SharedHealthCheckManager.get_model_health_check_cache_key("test-model")
assert key == "health_check_results:test-model"
@pytest.mark.asyncio
async def test_acquire_health_check_lock_success(self, shared_health_manager, mock_redis_cache):
"""Test successful lock acquisition"""
mock_redis_cache.async_set_cache.return_value = True
result = await shared_health_manager.acquire_health_check_lock()
assert result is True
mock_redis_cache.async_set_cache.assert_called_once_with(
"health_check_lock",
shared_health_manager.pod_id,
nx=True,
ttl=60,
)
@pytest.mark.asyncio
async def test_acquire_health_check_lock_failure(self, shared_health_manager, mock_redis_cache):
"""Test failed lock acquisition"""
mock_redis_cache.async_set_cache.return_value = False
result = await shared_health_manager.acquire_health_check_lock()
assert result is False
@pytest.mark.asyncio
async def test_acquire_health_check_lock_no_redis(self):
"""Test lock acquisition without Redis"""
manager = SharedHealthCheckManager(redis_cache=None)
result = await manager.acquire_health_check_lock()
assert result is False
@pytest.mark.asyncio
async def test_acquire_health_check_lock_exception(self, shared_health_manager, mock_redis_cache):
"""Test lock acquisition with exception"""
mock_redis_cache.async_set_cache.side_effect = Exception("Redis error")
result = await shared_health_manager.acquire_health_check_lock()
assert result is False
@pytest.mark.asyncio
async def test_release_health_check_lock_success(self, shared_health_manager, mock_redis_cache):
"""Test successful lock release"""
mock_redis_cache.async_get_cache.return_value = shared_health_manager.pod_id
await shared_health_manager.release_health_check_lock()
mock_redis_cache.async_get_cache.assert_called_once_with("health_check_lock")
mock_redis_cache.async_delete_cache.assert_called_once_with("health_check_lock")
@pytest.mark.asyncio
async def test_release_health_check_lock_wrong_owner(self, shared_health_manager, mock_redis_cache):
"""Test lock release when not the owner"""
mock_redis_cache.async_get_cache.return_value = "other_pod_id"
await shared_health_manager.release_health_check_lock()
mock_redis_cache.async_get_cache.assert_called_once_with("health_check_lock")
mock_redis_cache.async_delete_cache.assert_not_called()
@pytest.mark.asyncio
async def test_release_health_check_lock_no_redis(self):
"""Test lock release without Redis"""
manager = SharedHealthCheckManager(redis_cache=None)
# Should not raise exception
await manager.release_health_check_lock()
@pytest.mark.asyncio
async def test_get_cached_health_check_results_success(self, shared_health_manager, mock_redis_cache):
"""Test getting cached health check results successfully"""
current_time = time.time()
cached_data = {
"healthy_endpoints": [{"model": "test-model"}],
"unhealthy_endpoints": [],
"healthy_count": 1,
"unhealthy_count": 0,
"timestamp": current_time - 100, # 100 seconds ago
"checked_by": "test_pod",
}
mock_redis_cache.async_get_cache.return_value = json.dumps(cached_data)
result = await shared_health_manager.get_cached_health_check_results()
assert result is not None
assert result["healthy_count"] == 1
assert result["unhealthy_count"] == 0
@pytest.mark.asyncio
async def test_get_cached_health_check_results_expired(self, shared_health_manager, mock_redis_cache):
"""Test getting expired cached health check results"""
current_time = time.time()
cached_data = {
"healthy_endpoints": [{"model": "test-model"}],
"unhealthy_endpoints": [],
"healthy_count": 1,
"unhealthy_count": 0,
"timestamp": current_time - 400, # 400 seconds ago (expired)
"checked_by": "test_pod",
}
mock_redis_cache.async_get_cache.return_value = json.dumps(cached_data)
result = await shared_health_manager.get_cached_health_check_results()
assert result is None
@pytest.mark.asyncio
async def test_get_cached_health_check_results_no_cache(self, shared_health_manager, mock_redis_cache):
"""Test getting cached results when no cache exists"""
mock_redis_cache.async_get_cache.return_value = None
result = await shared_health_manager.get_cached_health_check_results()
assert result is None
@pytest.mark.asyncio
async def test_get_cached_health_check_results_no_redis(self):
"""Test getting cached results without Redis"""
manager = SharedHealthCheckManager(redis_cache=None)
result = await manager.get_cached_health_check_results()
assert result is None
@pytest.mark.asyncio
async def test_cache_health_check_results_success(self, shared_health_manager, mock_redis_cache):
"""Test caching health check results successfully"""
healthy_endpoints = [{"model": "test-model-1"}]
unhealthy_endpoints = [{"model": "test-model-2"}]
await shared_health_manager.cache_health_check_results(
healthy_endpoints, unhealthy_endpoints
)
mock_redis_cache.async_set_cache.assert_called_once()
call_args = mock_redis_cache.async_set_cache.call_args
assert call_args[0][0] == "health_check_results" # key
assert call_args[1]["ttl"] == 300 # ttl
# Verify cached data structure
cached_data = json.loads(call_args[0][1])
assert cached_data["healthy_endpoints"] == healthy_endpoints
assert cached_data["unhealthy_endpoints"] == unhealthy_endpoints
assert cached_data["healthy_count"] == 1
assert cached_data["unhealthy_count"] == 1
assert "timestamp" in cached_data
assert cached_data["checked_by"] == shared_health_manager.pod_id
@pytest.mark.asyncio
async def test_cache_health_check_results_no_redis(self):
"""Test caching results without Redis"""
manager = SharedHealthCheckManager(redis_cache=None)
# Should not raise exception
await manager.cache_health_check_results([], [])
@pytest.mark.asyncio
async def test_perform_shared_health_check_with_cache(self, shared_health_manager, mock_redis_cache):
"""Test performing shared health check when cache is available"""
# Mock cached results
cached_data = {
"healthy_endpoints": [{"model": "cached-model"}],
"unhealthy_endpoints": [],
"healthy_count": 1,
"unhealthy_count": 0,
"timestamp": time.time() - 100,
}
mock_redis_cache.async_get_cache.return_value = json.dumps(cached_data)
model_list = [{"model_name": "test-model", "litellm_params": {"model": "test-model"}}]
with patch("litellm.proxy.health_check_utils.shared_health_check_manager.perform_health_check") as mock_perform:
healthy, unhealthy, _ = await shared_health_manager.perform_shared_health_check(
model_list, details=True
)
# Should return cached results, not call perform_health_check
assert healthy == [{"model": "cached-model"}]
assert unhealthy == []
mock_perform.assert_not_called()
@pytest.mark.asyncio
async def test_perform_shared_health_check_with_lock_acquisition(self, shared_health_manager, mock_redis_cache):
"""Test performing shared health check when acquiring lock"""
# No cached results
mock_redis_cache.async_get_cache.return_value = None
# Lock acquisition succeeds
mock_redis_cache.async_set_cache.return_value = True
model_list = [{"model_name": "test-model", "litellm_params": {"model": "test-model"}}]
expected_healthy = [{"model": "test-model", "status": "healthy"}]
expected_unhealthy = []
with patch("litellm.proxy.health_check_utils.shared_health_check_manager.perform_health_check") as mock_perform:
mock_perform.return_value = (expected_healthy, expected_unhealthy, {})
healthy, unhealthy, _ = await shared_health_manager.perform_shared_health_check(
model_list, details=True
)
# Should call perform_health_check and cache results
mock_perform.assert_called_once_with(model_list=model_list, details=True, max_concurrency=None)
assert healthy == expected_healthy
assert unhealthy == expected_unhealthy
# Should cache the results
assert mock_redis_cache.async_set_cache.call_count >= 2 # Lock + cache
@pytest.mark.asyncio
async def test_perform_shared_health_check_lock_failed_then_cache(self, shared_health_manager, mock_redis_cache):
"""Test performing shared health check when lock fails but cache becomes available"""
# First call: no cache, lock fails
# Second call: cache available
mock_redis_cache.async_get_cache.side_effect = [
None, # No cache initially
json.dumps({ # Cache available after waiting
"healthy_endpoints": [{"model": "cached-model"}],
"unhealthy_endpoints": [],
"healthy_count": 1,
"unhealthy_count": 0,
"timestamp": time.time() - 100,
})
]
mock_redis_cache.async_set_cache.return_value = False # Lock acquisition fails
model_list = [{"model_name": "test-model", "litellm_params": {"model": "test-model"}}]
with patch("asyncio.sleep") as mock_sleep: # Mock sleep to avoid actual delay
healthy, unhealthy, _ = await shared_health_manager.perform_shared_health_check(
model_list, details=True
)
# Should wait and then get cached results
mock_sleep.assert_called_once_with(2)
assert healthy == [{"model": "cached-model"}]
assert unhealthy == []
@pytest.mark.asyncio
async def test_perform_shared_health_check_fallback(self, shared_health_manager, mock_redis_cache):
"""Test performing shared health check with fallback to local health check"""
# No cache, lock fails, no cache after waiting
mock_redis_cache.async_get_cache.return_value = None
mock_redis_cache.async_set_cache.return_value = False # Lock acquisition fails
model_list = [{"model_name": "test-model", "litellm_params": {"model": "test-model"}}]
expected_healthy = [{"model": "test-model", "status": "healthy"}]
expected_unhealthy = []
with patch("asyncio.sleep") as mock_sleep, \
patch("litellm.proxy.health_check_utils.shared_health_check_manager.perform_health_check") as mock_perform:
mock_perform.return_value = (expected_healthy, expected_unhealthy, {})
healthy, unhealthy, _ = await shared_health_manager.perform_shared_health_check(
model_list, details=True
)
# Should fall back to local health check
mock_sleep.assert_called_once_with(2)
mock_perform.assert_called_once_with(model_list=model_list, details=True, max_concurrency=None)
assert healthy == expected_healthy
assert unhealthy == expected_unhealthy
@pytest.mark.asyncio
async def test_is_health_check_in_progress_true(self, shared_health_manager, mock_redis_cache):
"""Test checking if health check is in progress when it is"""
mock_redis_cache.async_get_cache.return_value = "other_pod_id"
result = await shared_health_manager.is_health_check_in_progress()
assert result is True
@pytest.mark.asyncio
async def test_is_health_check_in_progress_false(self, shared_health_manager, mock_redis_cache):
"""Test checking if health check is in progress when it's not"""
mock_redis_cache.async_get_cache.return_value = None
result = await shared_health_manager.is_health_check_in_progress()
assert result is False
@pytest.mark.asyncio
async def test_is_health_check_in_progress_own_lock(self, shared_health_manager, mock_redis_cache):
"""Test checking if health check is in progress when we own the lock"""
mock_redis_cache.async_get_cache.return_value = shared_health_manager.pod_id
result = await shared_health_manager.is_health_check_in_progress()
assert result is False
@pytest.mark.asyncio
async def test_get_health_check_status(self, shared_health_manager, mock_redis_cache):
"""Test getting health check status"""
current_time = time.time()
cached_data = {
"healthy_endpoints": [{"model": "test-model"}],
"unhealthy_endpoints": [],
"healthy_count": 1,
"unhealthy_count": 0,
"timestamp": current_time - 100,
"checked_by": "test_pod",
}
mock_redis_cache.async_get_cache.side_effect = [
"other_pod_id", # Lock owner
json.dumps(cached_data), # Cached results
]
status = await shared_health_manager.get_health_check_status()
assert status["pod_id"] == shared_health_manager.pod_id
assert status["redis_available"] is True
assert status["lock_ttl"] == 60
assert status["cache_ttl"] == 300
assert status["lock_owner"] == "other_pod_id"
assert status["lock_in_progress"] is True
assert status["cache_available"] is True
assert status["last_checked_by"] == "test_pod"
assert "cache_age_seconds" in status
@pytest.mark.asyncio
async def test_get_health_check_status_no_redis(self):
"""Test getting health check status without Redis"""
manager = SharedHealthCheckManager(redis_cache=None)
status = await manager.get_health_check_status()
assert status["pod_id"] == manager.pod_id
assert status["redis_available"] is False
assert status["lock_ttl"] == 60
assert status["cache_ttl"] == 300