Merge pull request #2 from Sameerlite/litellm_litellm_health-check-driven-routing2

feat(router): health-check-driven routing
This commit is contained in:
Sameer Kankute
2026-03-27 21:05:56 +05:30
committed by GitHub
8 changed files with 694 additions and 11 deletions
+83
View File
@@ -314,6 +314,89 @@ general_settings:
health_check_details: False
```
## Health Check Driven Routing
By default, background health checks are observability-only — they populate the `/health` endpoint but don't affect routing. Unhealthy deployments still receive traffic until request failures trigger cooldown.
With `enable_health_check_routing: true`, the router **excludes deployments that failed their last background health check** before selecting a candidate. This gives you proactive failover instead of reactive cooldown.
### How it works
1. Background health checks run on their configured interval
2. After each cycle, every deployment is marked healthy or unhealthy
3. On each incoming request, the router filters out unhealthy deployments **before** cooldown filtering and load balancing
4. If all deployments are unhealthy, the filter is bypassed (safety net — never causes a total outage)
5. If health state is stale (older than `health_check_staleness_threshold`), it is ignored
### Quick start
```yaml
model_list:
- model_name: gpt-4
litellm_params:
model: openai/gpt-4
api_key: os.environ/OPENAI_API_KEY
- model_name: gpt-4
litellm_params:
model: openai/gpt-4
api_key: os.environ/OPENAI_API_KEY_SECONDARY
general_settings:
background_health_checks: true
health_check_interval: 60
enable_health_check_routing: true
```
### Configuration
| Setting | Where | Default | Description |
|---------|-------|---------|-------------|
| `enable_health_check_routing` | `general_settings` | `false` | Enable/disable health-check-driven routing |
| `health_check_staleness_threshold` | `general_settings` | `health_check_interval * 2` | Seconds before health state is considered stale and ignored |
| `background_health_checks` | `general_settings` | `false` | Must be `true` for health check routing to work |
| `health_check_interval` | `general_settings` | `300` | Seconds between health check cycles |
### Interaction with cooldown
Health check filtering and cooldown are **additive**. A deployment can be excluded by either mechanism:
- **Health check filter** — proactive, runs on the configured interval, excludes deployments that failed the last check
- **Cooldown** — reactive, triggered by request failures, excludes deployments for a short TTL
This means request failures still provide fast detection between health check intervals.
### Staleness
If a health check result is older than `health_check_staleness_threshold`, it is ignored and the deployment is treated as eligible. This prevents stale data from permanently excluding a deployment if the health check loop stops or slows down.
The default staleness threshold is `health_check_interval * 2`. For a 60s interval, health state expires after 120s.
### Example: custom staleness
```yaml
general_settings:
background_health_checks: true
health_check_interval: 30
enable_health_check_routing: true
health_check_staleness_threshold: 90 # ignore health state older than 90s
```
### Debugging
Run the proxy with `--detailed_debug` and look for:
```
health_check_routing_state_updated healthy=3 unhealthy=1
```
This is logged after each health check cycle when routing state is written.
If the safety net triggers (all deployments unhealthy), you'll see:
```
All deployments marked unhealthy by health checks, bypassing health filter
```
## Health Check Timeout
The health check timeout is set in `litellm/constants.py` and defaults to 60 seconds.
+3
View File
@@ -1402,6 +1402,9 @@ DEFAULT_SHARED_HEALTH_CHECK_TTL = int(
DEFAULT_SHARED_HEALTH_CHECK_LOCK_TTL = int(
os.getenv("DEFAULT_SHARED_HEALTH_CHECK_LOCK_TTL", 60)
) # 1 minute - TTL for health check lock
DEFAULT_HEALTH_CHECK_STALENESS_MULTIPLIER = (
2 # health state is stale after interval * this
)
PROMETHEUS_FALLBACK_STATS_SEND_TIME_HOURS = int(
os.getenv("PROMETHEUS_FALLBACK_STATS_SEND_TIME_HOURS", 9)
)
+51 -7
View File
@@ -207,21 +207,65 @@ async def _perform_health_check(
for is_healthy, model in zip(results, model_list):
litellm_params = model["litellm_params"]
_model_id = (model.get("model_info") or {}).get("id")
if isinstance(is_healthy, dict) and "error" not in is_healthy:
healthy_endpoints.append(
_clean_endpoint_data({**litellm_params, **is_healthy}, details)
)
cleaned = _clean_endpoint_data({**litellm_params, **is_healthy}, details)
if _model_id:
cleaned["model_id"] = _model_id
healthy_endpoints.append(cleaned)
elif isinstance(is_healthy, dict):
unhealthy_endpoints.append(
_clean_endpoint_data({**litellm_params, **is_healthy}, details)
)
cleaned = _clean_endpoint_data({**litellm_params, **is_healthy}, details)
if _model_id:
cleaned["model_id"] = _model_id
unhealthy_endpoints.append(cleaned)
else:
unhealthy_endpoints.append(_clean_endpoint_data(litellm_params, details))
cleaned = _clean_endpoint_data(litellm_params, details)
if _model_id:
cleaned["model_id"] = _model_id
unhealthy_endpoints.append(cleaned)
return healthy_endpoints, unhealthy_endpoints
def build_deployment_health_states(
healthy_endpoints: list,
unhealthy_endpoints: list,
) -> dict:
"""
Build a dict mapping deployment_id -> DeploymentHealthStateValue from
health check endpoint results.
Each endpoint dict includes a 'model_id' field (added by _perform_health_check)
that maps back to the deployment's model_info.id.
Used by the background health check loop to feed health state into
the router's DeploymentHealthCache for health-check-driven routing.
"""
now = time.time()
states: dict = {}
for ep in healthy_endpoints:
model_id = ep.get("model_id")
if model_id:
states[model_id] = {
"is_healthy": True,
"timestamp": now,
"reason": "",
}
for ep in unhealthy_endpoints:
model_id = ep.get("model_id")
if model_id:
states[model_id] = {
"is_healthy": False,
"timestamp": now,
"reason": "background_health_check_failed",
}
return states
def _update_litellm_params_for_health_check(
model_info: dict, litellm_params: dict
) -> dict:
+51 -2
View File
@@ -480,11 +480,11 @@ from litellm.proxy.search_endpoints.search_tool_management import (
router as search_tool_management_router,
)
from litellm.proxy.spend_tracking.cloudzero_endpoints import router as cloudzero_router
from litellm.proxy.spend_tracking.vantage_endpoints import router as vantage_router
from litellm.proxy.spend_tracking.spend_management_endpoints import (
router as spend_management_router,
)
from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload
from litellm.proxy.spend_tracking.vantage_endpoints import router as vantage_router
from litellm.proxy.types_utils.utils import get_instance_fn
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
router as ui_crud_endpoints_router,
@@ -2112,6 +2112,37 @@ def _schedule_background_health_check_db_save(
)
def _write_health_state_to_router_cache(
healthy_endpoints: list,
unhealthy_endpoints: list,
) -> None:
"""
Write deployment health states to the router's health state cache
for health-check-driven routing. No-op if the feature is disabled.
"""
from litellm.proxy.health_check import build_deployment_health_states
try:
if llm_router is None or not llm_router.enable_health_check_routing:
return
states = build_deployment_health_states(
healthy_endpoints=healthy_endpoints,
unhealthy_endpoints=unhealthy_endpoints,
)
if states:
llm_router.health_state_cache.set_deployment_health_states(states)
verbose_proxy_logger.debug(
"health_check_routing_state_updated healthy=%d unhealthy=%d",
sum(1 for s in states.values() if s.get("is_healthy")),
sum(1 for s in states.values() if not s.get("is_healthy")),
)
except Exception as e:
verbose_proxy_logger.warning(
"Failed to write health state to router cache: %s", str(e)
)
async def _run_background_health_check():
"""
Periodically run health checks in the background on the endpoints.
@@ -2281,6 +2312,9 @@ async def _run_background_health_check():
unhealthy_endpoints,
)
# Write health state to router cache for health-check-driven routing
_write_health_state_to_router_cache(healthy_endpoints, unhealthy_endpoints)
await asyncio.sleep(health_check_interval)
@@ -3048,6 +3082,8 @@ class ProxyConfig:
general_settings = config.get("general_settings", {})
if general_settings is None:
general_settings = {}
_enable_hc_routing = False
_hc_staleness = None
if general_settings:
### LOAD KEY MANAGEMENT SETTINGS FIRST (needed for custom secret manager) ###
key_management_settings = general_settings.get(
@@ -3227,13 +3263,21 @@ class ProxyConfig:
"health_check_concurrency", None
)
health_check_details = general_settings.get("health_check_details", True)
# Health-check-driven routing (opt-in, passes through to Router later)
_enable_hc_routing = general_settings.get(
"enable_health_check_routing", False
)
_hc_staleness = general_settings.get(
"health_check_staleness_threshold", None
)
verbose_proxy_logger.info(
"background_health_check_config enabled=%s shared=%s interval_seconds=%s max_concurrency=%s details=%s",
"background_health_check_config enabled=%s shared=%s interval_seconds=%s max_concurrency=%s details=%s health_check_routing=%s",
use_background_health_checks,
use_shared_health_check,
health_check_interval,
health_check_concurrency,
health_check_details,
_enable_hc_routing,
)
### RBAC ###
@@ -3263,6 +3307,11 @@ class ProxyConfig:
"cache_responses": litellm.cache
is not None, # cache if user passed in cache values
}
# Health-check-driven routing params (from general_settings)
if _enable_hc_routing:
router_params["enable_health_check_routing"] = True
if _hc_staleness is not None:
router_params["health_check_staleness_threshold"] = _hc_staleness
## MODEL LIST
model_list = config.get("model_list", None)
if model_list:
+96 -2
View File
@@ -54,7 +54,11 @@ from litellm.caching.caching import (
RedisCache,
RedisClusterCache,
)
from litellm.constants import DEFAULT_MAX_LRU_CACHE_SIZE
from litellm.constants import (
DEFAULT_HEALTH_CHECK_INTERVAL,
DEFAULT_HEALTH_CHECK_STALENESS_MULTIPLIER,
DEFAULT_MAX_LRU_CACHE_SIZE,
)
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.asyncify import run_async_function
from litellm.litellm_core_utils.core_helpers import (
@@ -113,6 +117,7 @@ from litellm.router_utils.handle_error import (
async_raise_no_deployment_exception,
send_llm_exception_alert,
)
from litellm.router_utils.health_state_cache import DeploymentHealthCache
from litellm.router_utils.pre_call_checks.deployment_affinity_check import (
DeploymentAffinityCheck,
)
@@ -303,6 +308,8 @@ class Router:
deployment_affinity_ttl_seconds: int = 3600,
model_group_affinity_config: Optional[Dict[str, List[str]]] = None,
ignore_invalid_deployments: bool = False,
enable_health_check_routing: bool = False,
health_check_staleness_threshold: Optional[int] = None,
) -> None:
"""
Initialize the Router class with the given parameters for caching, reliability, and routing strategy.
@@ -493,6 +500,13 @@ class Router:
cache=self.cache, default_cooldown_time=self.cooldown_time
)
self.disable_cooldowns = disable_cooldowns
self.enable_health_check_routing = enable_health_check_routing
_staleness = health_check_staleness_threshold or (
DEFAULT_HEALTH_CHECK_INTERVAL * DEFAULT_HEALTH_CHECK_STALENESS_MULTIPLIER
)
self.health_state_cache = DeploymentHealthCache(
cache=self.cache, staleness_threshold=float(_staleness)
)
self.failed_calls = (
InMemoryCache()
) # cache to track failed call per deployment, if num failed calls within 1 minute > allowed fails, then add it to cooldown
@@ -9154,6 +9168,14 @@ class Router:
if isinstance(healthy_deployments, dict):
return healthy_deployments
# Health-check-based filtering (before cooldown)
healthy_deployments = (
await self._async_filter_health_check_unhealthy_deployments(
healthy_deployments=healthy_deployments,
parent_otel_span=parent_otel_span,
)
)
cooldown_deployments = await _async_get_cooldown_deployments(
litellm_router_instance=self, parent_otel_span=parent_otel_span
)
@@ -9585,6 +9607,13 @@ class Router:
parent_otel_span: Optional[Span] = _get_parent_otel_span_from_kwargs(
request_kwargs
)
# Health-check-based filtering (before cooldown)
healthy_deployments = self._filter_health_check_unhealthy_deployments(
healthy_deployments=healthy_deployments,
parent_otel_span=parent_otel_span,
)
cooldown_deployments = _get_cooldown_deployments(
litellm_router_instance=self, parent_otel_span=parent_otel_span
)
@@ -9750,10 +9779,14 @@ class Router:
llm_provider="",
)
# 4. Apply cooldown filtering
# 4. Apply health-check and cooldown filtering
parent_otel_span: Optional[Span] = _get_parent_otel_span_from_kwargs(
request_kwargs
)
pass_through_deployments = self._filter_health_check_unhealthy_deployments(
healthy_deployments=pass_through_deployments,
parent_otel_span=parent_otel_span,
)
cooldown_deployments = _get_cooldown_deployments(
litellm_router_instance=self, parent_otel_span=parent_otel_span
)
@@ -9875,6 +9908,67 @@ class Router:
if deployment["model_info"]["id"] not in cooldown_set
]
async def _async_filter_health_check_unhealthy_deployments(
self,
healthy_deployments: List[Dict],
parent_otel_span: Optional[Span] = None,
) -> List[Dict]:
"""
Filter out deployments marked unhealthy by background health checks.
No-op when enable_health_check_routing is False.
Returns all deployments if health state is unavailable, stale, or would
exclude every candidate (safety net).
"""
if not self.enable_health_check_routing:
return healthy_deployments
unhealthy_ids = (
await self.health_state_cache.async_get_unhealthy_deployment_ids(
parent_otel_span=parent_otel_span
)
)
if not unhealthy_ids:
return healthy_deployments
filtered = [
d for d in healthy_deployments if d["model_info"]["id"] not in unhealthy_ids
]
if not filtered:
verbose_router_logger.warning(
"All deployments marked unhealthy by health checks, bypassing health filter"
)
return healthy_deployments
return filtered
def _filter_health_check_unhealthy_deployments(
self,
healthy_deployments: List[Dict],
parent_otel_span: Optional[Span] = None,
) -> List[Dict]:
"""Sync version of _async_filter_health_check_unhealthy_deployments."""
if not self.enable_health_check_routing:
return healthy_deployments
unhealthy_ids = self.health_state_cache.get_unhealthy_deployment_ids(
parent_otel_span=parent_otel_span
)
if not unhealthy_ids:
return healthy_deployments
filtered = [
d for d in healthy_deployments if d["model_info"]["id"] not in unhealthy_ids
]
if not filtered:
verbose_router_logger.warning(
"All deployments marked unhealthy by health checks, bypassing health filter"
)
return healthy_deployments
return filtered
def _filter_pass_through_deployments(
self, healthy_deployments: List[Dict]
) -> List[Dict]:
+100
View File
@@ -0,0 +1,100 @@
"""
Wrapper around router cache for health-check-driven routing.
Stores per-deployment health state from background health checks
and exposes it for router candidate filtering.
"""
import time
from typing import TYPE_CHECKING, Any, Dict, Optional, Set, Union
from typing_extensions import TypedDict
from litellm import verbose_logger
from litellm.caching.caching import DualCache
if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
Span = Union[_Span, Any]
else:
Span = Any
class DeploymentHealthStateValue(TypedDict):
is_healthy: bool
timestamp: float
reason: str
class DeploymentHealthCache:
"""
Cache for deployment health states produced by background health checks.
Stores a single dict mapping deployment_id -> DeploymentHealthStateValue.
Staleness is enforced at read time: entries older than staleness_threshold
are treated as healthy (unknown).
"""
CACHE_KEY = "litellm:health_check:deployment_health_state"
def __init__(self, cache: DualCache, staleness_threshold: float):
self.cache = cache
self.staleness_threshold = staleness_threshold
def set_deployment_health_states(
self, states: Dict[str, DeploymentHealthStateValue]
) -> None:
"""Bulk-write all deployment health states as a single cache entry."""
try:
self.cache.set_cache(
key=self.CACHE_KEY,
value=states,
ttl=int(self.staleness_threshold * 1.5),
)
except Exception as e:
verbose_logger.error(
"DeploymentHealthCache::set_deployment_health_states - Exception: %s",
str(e),
)
def _extract_unhealthy_ids(self, raw: Any) -> Set[str]:
"""Given raw cache value, return set of non-stale unhealthy deployment IDs."""
if not raw or not isinstance(raw, dict):
return set()
now = time.time()
return {
model_id
for model_id, state in raw.items()
if isinstance(state, dict)
and not state.get("is_healthy", True)
and (now - state.get("timestamp", 0)) < self.staleness_threshold
}
async def async_get_unhealthy_deployment_ids(
self, parent_otel_span: Optional[Span] = None
) -> Set[str]:
"""Return set of deployment IDs currently marked unhealthy and not stale."""
try:
raw = await self.cache.async_get_cache(key=self.CACHE_KEY)
return self._extract_unhealthy_ids(raw)
except Exception as e:
verbose_logger.debug(
"DeploymentHealthCache::async_get_unhealthy_deployment_ids - Exception: %s",
str(e),
)
return set()
def get_unhealthy_deployment_ids(
self, parent_otel_span: Optional[Span] = None
) -> Set[str]:
"""Sync version: return set of deployment IDs currently marked unhealthy and not stale."""
try:
raw = self.cache.get_cache(key=self.CACHE_KEY)
return self._extract_unhealthy_ids(raw)
except Exception as e:
verbose_logger.debug(
"DeploymentHealthCache::get_unhealthy_deployment_ids - Exception: %s",
str(e),
)
return set()
@@ -0,0 +1,197 @@
"""
Tests for health-check-driven routing filter in the Router.
"""
import time
import pytest
from litellm.caching.caching import DualCache
from litellm.router_utils.health_state_cache import DeploymentHealthCache
def _make_deployment(model_id: str, model_name: str = "gpt-4") -> dict:
"""Helper to create a deployment dict for testing."""
return {
"model_name": model_name,
"litellm_params": {"model": model_name, "api_key": "fake"},
"model_info": {"id": model_id},
}
def _make_health_cache(
unhealthy_ids: set = None, staleness_threshold: float = 60.0
) -> DeploymentHealthCache:
"""Create a health cache pre-populated with unhealthy deployment IDs."""
cache = DualCache()
health_cache = DeploymentHealthCache(
cache=cache, staleness_threshold=staleness_threshold
)
if unhealthy_ids:
now = time.time()
states = {}
for uid in unhealthy_ids:
states[uid] = {
"is_healthy": False,
"timestamp": now,
"reason": "test_unhealthy",
}
health_cache.set_deployment_health_states(states)
return health_cache
class TestFilterHealthCheckUnhealthyDeployments:
"""Test the sync filter method."""
def _make_router_like(self, enable: bool, health_cache: DeploymentHealthCache):
"""Create a minimal object that behaves like Router for filter testing."""
class FakeRouter:
def __init__(self):
self.enable_health_check_routing = enable
self.health_state_cache = health_cache
# Import the actual method and bind it
from litellm.router import Router
fake = FakeRouter()
# Use the unbound method
fake._filter_health_check_unhealthy_deployments = (
Router._filter_health_check_unhealthy_deployments.__get__(fake, FakeRouter)
)
return fake
def test_filter_removes_unhealthy_deployments(self):
"""Unhealthy deployments should be removed from candidates."""
health_cache = _make_health_cache(unhealthy_ids={"deploy-2"})
router = self._make_router_like(enable=True, health_cache=health_cache)
deployments = [
_make_deployment("deploy-1"),
_make_deployment("deploy-2"),
_make_deployment("deploy-3"),
]
result = router._filter_health_check_unhealthy_deployments(deployments)
assert len(result) == 2
assert all(d["model_info"]["id"] != "deploy-2" for d in result)
def test_filter_noop_when_disabled(self):
"""When enable_health_check_routing=False, filter should be a no-op."""
health_cache = _make_health_cache(unhealthy_ids={"deploy-1"})
router = self._make_router_like(enable=False, health_cache=health_cache)
deployments = [
_make_deployment("deploy-1"),
_make_deployment("deploy-2"),
]
result = router._filter_health_check_unhealthy_deployments(deployments)
assert len(result) == 2 # no filtering
def test_filter_returns_all_when_all_unhealthy(self):
"""Safety net: if ALL deployments are unhealthy, return all (don't cause outage)."""
health_cache = _make_health_cache(
unhealthy_ids={"deploy-1", "deploy-2", "deploy-3"}
)
router = self._make_router_like(enable=True, health_cache=health_cache)
deployments = [
_make_deployment("deploy-1"),
_make_deployment("deploy-2"),
_make_deployment("deploy-3"),
]
result = router._filter_health_check_unhealthy_deployments(deployments)
assert len(result) == 3 # all returned, safety net
def test_filter_returns_all_when_cache_empty(self):
"""When cache is empty, all deployments should pass through."""
health_cache = _make_health_cache() # empty
router = self._make_router_like(enable=True, health_cache=health_cache)
deployments = [
_make_deployment("deploy-1"),
_make_deployment("deploy-2"),
]
result = router._filter_health_check_unhealthy_deployments(deployments)
assert len(result) == 2
class TestAsyncFilterHealthCheckUnhealthyDeployments:
"""Test the async filter method."""
def _make_router_like(self, enable: bool, health_cache: DeploymentHealthCache):
from litellm.router import Router
class FakeRouter:
def __init__(self):
self.enable_health_check_routing = enable
self.health_state_cache = health_cache
fake = FakeRouter()
fake._async_filter_health_check_unhealthy_deployments = (
Router._async_filter_health_check_unhealthy_deployments.__get__(
fake, FakeRouter
)
)
return fake
@pytest.mark.asyncio
async def test_async_filter_removes_unhealthy(self):
"""Async version: unhealthy deployments removed."""
health_cache = _make_health_cache(unhealthy_ids={"deploy-2"})
router = self._make_router_like(enable=True, health_cache=health_cache)
deployments = [
_make_deployment("deploy-1"),
_make_deployment("deploy-2"),
_make_deployment("deploy-3"),
]
result = await router._async_filter_health_check_unhealthy_deployments(
healthy_deployments=deployments
)
assert len(result) == 2
assert all(d["model_info"]["id"] != "deploy-2" for d in result)
@pytest.mark.asyncio
async def test_async_filter_safety_net(self):
"""Async version: safety net when all unhealthy."""
health_cache = _make_health_cache(unhealthy_ids={"deploy-1", "deploy-2"})
router = self._make_router_like(enable=True, health_cache=health_cache)
deployments = [
_make_deployment("deploy-1"),
_make_deployment("deploy-2"),
]
result = await router._async_filter_health_check_unhealthy_deployments(
healthy_deployments=deployments
)
assert len(result) == 2 # safety net
class TestBuildDeploymentHealthStates:
"""Test the build_deployment_health_states function."""
def test_builds_states_from_endpoints(self):
from litellm.proxy.health_check import build_deployment_health_states
healthy = [{"model": "gpt-4", "model_id": "deploy-1"}]
unhealthy = [{"model": "gpt-4", "model_id": "deploy-2", "error": "timeout"}]
states = build_deployment_health_states(healthy, unhealthy)
assert states["deploy-1"]["is_healthy"] is True
assert states["deploy-2"]["is_healthy"] is False
def test_no_model_id_skipped(self):
from litellm.proxy.health_check import build_deployment_health_states
healthy = [{"model": "gpt-4"}] # no model_id
unhealthy = [{"model": "gpt-4", "model_id": "deploy-2"}]
states = build_deployment_health_states(healthy, unhealthy)
assert "deploy-1" not in states
assert states["deploy-2"]["is_healthy"] is False
def test_empty_endpoints(self):
from litellm.proxy.health_check import build_deployment_health_states
states = build_deployment_health_states([], [])
assert states == {}
@@ -0,0 +1,113 @@
"""
Tests for DeploymentHealthCache - the cache layer for health-check-driven routing.
"""
import time
import pytest
from litellm.caching.caching import DualCache
from litellm.router_utils.health_state_cache import DeploymentHealthCache
@pytest.fixture
def cache():
return DualCache()
@pytest.fixture
def health_cache(cache):
return DeploymentHealthCache(cache=cache, staleness_threshold=60.0)
def test_set_and_get_unhealthy_ids(health_cache):
"""Write states, verify unhealthy set is returned correctly."""
now = time.time()
states = {
"deploy-1": {"is_healthy": True, "timestamp": now, "reason": ""},
"deploy-2": {"is_healthy": False, "timestamp": now, "reason": "check_failed"},
"deploy-3": {"is_healthy": False, "timestamp": now, "reason": "timeout"},
}
health_cache.set_deployment_health_states(states)
result = health_cache.get_unhealthy_deployment_ids()
assert result == {"deploy-2", "deploy-3"}
@pytest.mark.asyncio
async def test_async_get_unhealthy_ids(health_cache):
"""Async version of set and get."""
now = time.time()
states = {
"deploy-1": {"is_healthy": True, "timestamp": now, "reason": ""},
"deploy-2": {"is_healthy": False, "timestamp": now, "reason": "check_failed"},
}
health_cache.set_deployment_health_states(states)
result = await health_cache.async_get_unhealthy_deployment_ids()
assert result == {"deploy-2"}
def test_staleness_filtering(health_cache):
"""Entries older than staleness_threshold should be ignored."""
old_time = time.time() - 120 # 2 minutes ago, threshold is 60s
states = {
"deploy-1": {
"is_healthy": False,
"timestamp": old_time,
"reason": "check_failed",
},
}
health_cache.set_deployment_health_states(states)
result = health_cache.get_unhealthy_deployment_ids()
assert result == set() # stale entry should be ignored
def test_empty_cache_returns_empty_set(health_cache):
"""No data in cache should return empty set."""
result = health_cache.get_unhealthy_deployment_ids()
assert result == set()
def test_all_healthy_returns_empty_set(health_cache):
"""All healthy deployments should return empty set."""
now = time.time()
states = {
"deploy-1": {"is_healthy": True, "timestamp": now, "reason": ""},
"deploy-2": {"is_healthy": True, "timestamp": now, "reason": ""},
}
health_cache.set_deployment_health_states(states)
result = health_cache.get_unhealthy_deployment_ids()
assert result == set()
def test_mixed_stale_and_fresh(health_cache):
"""Only fresh unhealthy entries should be returned."""
now = time.time()
old_time = now - 120 # stale
states = {
"deploy-1": {
"is_healthy": False,
"timestamp": old_time,
"reason": "stale",
},
"deploy-2": {
"is_healthy": False,
"timestamp": now,
"reason": "fresh",
},
}
health_cache.set_deployment_health_states(states)
result = health_cache.get_unhealthy_deployment_ids()
assert result == {"deploy-2"}
def test_malformed_state_entries_are_skipped(health_cache):
"""Non-dict entries in the cache should be skipped safely."""
now = time.time()
states = {
"deploy-1": {"is_healthy": False, "timestamp": now, "reason": "bad"},
"deploy-2": "not_a_dict", # malformed
"deploy-3": None, # malformed
}
health_cache.set_deployment_health_states(states)
result = health_cache.get_unhealthy_deployment_ids()
assert result == {"deploy-1"}