mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-20 00:11:50 +00:00
298 lines
11 KiB
Python
298 lines
11 KiB
Python
from importlib import import_module
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from litellm._redis import (
|
|
_coerce_redis_kwargs_types,
|
|
_get_redis_client_logic,
|
|
_get_redis_env_kwarg_mapping,
|
|
get_redis_async_client,
|
|
get_redis_connection_pool,
|
|
)
|
|
|
|
|
|
def test_url_config_uses_passed_pool():
|
|
"""When connection_pool is provided with a URL config, the client
|
|
should use the passed pool — not create a new one via from_url()."""
|
|
mock_pool = MagicMock()
|
|
|
|
with patch("litellm._redis._get_redis_client_logic") as mock_logic:
|
|
mock_logic.return_value = {"url": "redis://localhost:6379/0"}
|
|
|
|
client = get_redis_async_client(connection_pool=mock_pool)
|
|
|
|
assert client.connection_pool is mock_pool
|
|
|
|
|
|
def test_url_config_falls_back_to_from_url_without_pool():
|
|
"""When no connection_pool is provided, URL config should still
|
|
use from_url() as before."""
|
|
with patch("litellm._redis._get_redis_client_logic") as mock_logic:
|
|
mock_logic.return_value = {"url": "redis://localhost:6379/0"}
|
|
|
|
client = get_redis_async_client()
|
|
|
|
# from_url creates its own pool — just verify it's not None
|
|
assert client.connection_pool is not None
|
|
|
|
|
|
def test_max_connections_url_config(monkeypatch):
|
|
"""max_connections should be respected when using URL-based config."""
|
|
monkeypatch.setenv("REDIS_URL", "redis://localhost:6379/0")
|
|
monkeypatch.delenv("REDIS_HOST", raising=False)
|
|
monkeypatch.setenv("REDIS_MAX_CONNECTIONS", "10")
|
|
|
|
pool = get_redis_connection_pool()
|
|
|
|
assert pool.max_connections == 10
|
|
|
|
|
|
def test_max_connections_url_config_string_value(monkeypatch):
|
|
"""max_connections provided as a string (from env var) should be
|
|
cast to int."""
|
|
monkeypatch.setenv("REDIS_URL", "redis://localhost:6379/0")
|
|
monkeypatch.delenv("REDIS_HOST", raising=False)
|
|
monkeypatch.setenv("REDIS_MAX_CONNECTIONS", "25")
|
|
|
|
pool = get_redis_connection_pool()
|
|
|
|
assert pool.max_connections == 25
|
|
|
|
|
|
def test_max_connections_url_config_invalid_value(monkeypatch):
|
|
"""Invalid max_connections from an env var should be silently dropped,
|
|
falling back to the pool default (50 for BlockingConnectionPool)."""
|
|
monkeypatch.setenv("REDIS_URL", "redis://localhost:6379/0")
|
|
monkeypatch.delenv("REDIS_HOST", raising=False)
|
|
monkeypatch.setenv("REDIS_MAX_CONNECTIONS", "not_a_number")
|
|
|
|
pool = get_redis_connection_pool()
|
|
|
|
# BlockingConnectionPool default is 50
|
|
assert pool.max_connections == 50
|
|
|
|
|
|
def test_max_connections_url_config_none_value():
|
|
"""max_connections=None should be silently ignored."""
|
|
with patch("litellm._redis._get_redis_client_logic") as mock_logic:
|
|
mock_logic.return_value = {
|
|
"url": "redis://localhost:6379/0",
|
|
"max_connections": None,
|
|
}
|
|
|
|
pool = get_redis_connection_pool()
|
|
|
|
assert pool.max_connections == 50
|
|
|
|
|
|
def _make_redis_cache():
|
|
"""Create a RedisCache with all external I/O mocked out."""
|
|
mock_sync_client = MagicMock()
|
|
mock_async_pool = AsyncMock()
|
|
patches = [
|
|
patch("litellm._redis.get_redis_client", return_value=mock_sync_client),
|
|
patch("litellm._redis.get_redis_connection_pool", return_value=mock_async_pool),
|
|
patch.object(import_module("litellm.caching.redis_cache").RedisCache, "_setup_health_pings"),
|
|
]
|
|
for p in patches:
|
|
p.start()
|
|
|
|
from litellm.caching.redis_cache import RedisCache
|
|
|
|
cache = RedisCache(host="localhost", port=6379)
|
|
|
|
for p in patches:
|
|
p.stop()
|
|
|
|
return cache, mock_sync_client, mock_async_pool
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_closes_sync_client():
|
|
"""disconnect() should close both the async pool and the sync client."""
|
|
cache, mock_sync_client, mock_async_pool = _make_redis_cache()
|
|
await cache.disconnect()
|
|
|
|
mock_async_pool.disconnect.assert_awaited_once_with(inuse_connections=True)
|
|
mock_sync_client.close.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_idempotent():
|
|
"""Calling disconnect() twice should not raise."""
|
|
cache, mock_sync_client, mock_async_pool = _make_redis_cache()
|
|
mock_sync_client.close.side_effect = [None, RuntimeError("already closed")]
|
|
|
|
await cache.disconnect()
|
|
await cache.disconnect() # should not raise
|
|
|
|
|
|
def test_coerce_redis_kwargs_types_int():
|
|
"""String values for int-typed Redis params are coerced to int."""
|
|
result = _coerce_redis_kwargs_types({"health_check_interval": "30", "port": "6380", "db": "1"})
|
|
assert result["health_check_interval"] == 30
|
|
assert isinstance(result["health_check_interval"], int)
|
|
assert result["port"] == 6380
|
|
assert result["db"] == 1
|
|
|
|
|
|
def test_coerce_redis_kwargs_types_bool():
|
|
"""String values for bool-typed Redis params are coerced to bool."""
|
|
result = _coerce_redis_kwargs_types({"ssl": "true", "decode_responses": "false"})
|
|
assert result["ssl"] is True
|
|
assert result["decode_responses"] is False
|
|
|
|
|
|
def test_coerce_redis_kwargs_types_none_default_numeric():
|
|
"""String values for known None-default numeric params are coerced."""
|
|
result = _coerce_redis_kwargs_types({"max_connections": "20", "socket_timeout": "5.5"})
|
|
assert result["max_connections"] == 20
|
|
assert isinstance(result["max_connections"], int)
|
|
assert result["socket_timeout"] == 5.5
|
|
assert isinstance(result["socket_timeout"], float)
|
|
|
|
|
|
def _redis_signature_pre_8x(
|
|
socket_timeout=None,
|
|
socket_connect_timeout=None,
|
|
max_connections=None,
|
|
health_check_interval=0,
|
|
):
|
|
"""Stand-in for the redis-py <= 7.x Redis signature, where the timeout defaults are None."""
|
|
|
|
|
|
def _redis_signature_8x(
|
|
socket_timeout=5,
|
|
socket_connect_timeout=5,
|
|
max_connections=None,
|
|
health_check_interval=0,
|
|
):
|
|
"""Stand-in for the redis-py 8.x Redis signature, where the timeout defaults became int 5."""
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"client",
|
|
[_redis_signature_pre_8x, _redis_signature_8x],
|
|
ids=["redis-py<=7.x", "redis-py-8.x"],
|
|
)
|
|
def test_coerce_fractional_socket_timeout_survives_signature_default_change(client):
|
|
"""redis-py 8.x changed socket_timeout's default from None to int 5. Deriving the
|
|
target type from the signature default made int("5.5") raise, so the key was dropped
|
|
and REDIS_SOCKET_TIMEOUT=5.5 silently disappeared on 8.x."""
|
|
result = _coerce_redis_kwargs_types(
|
|
{"socket_timeout": "5.5", "socket_connect_timeout": "2.5", "max_connections": "20"},
|
|
client=client,
|
|
)
|
|
|
|
assert result["socket_timeout"] == pytest.approx(5.5)
|
|
assert isinstance(result["socket_timeout"], float)
|
|
assert result["socket_connect_timeout"] == pytest.approx(2.5)
|
|
assert isinstance(result["socket_connect_timeout"], float)
|
|
assert result["max_connections"] == 20
|
|
assert isinstance(result["max_connections"], int)
|
|
|
|
|
|
def test_coerce_invalid_socket_timeout_is_still_dropped():
|
|
"""Garbage must not survive the explicit-type path; Redis falls back to its own default."""
|
|
result = _coerce_redis_kwargs_types({"socket_timeout": "not_a_number"}, client=_redis_signature_8x)
|
|
|
|
assert "socket_timeout" not in result
|
|
|
|
|
|
def test_coerce_redis_kwargs_types_invalid_drops_key():
|
|
"""A string that cannot be coerced to the expected numeric type is dropped."""
|
|
result = _coerce_redis_kwargs_types({"health_check_interval": "not_a_number"})
|
|
assert "health_check_interval" not in result
|
|
|
|
|
|
def test_coerce_redis_kwargs_types_non_string_unchanged():
|
|
"""Non-string values pass through without modification."""
|
|
result = _coerce_redis_kwargs_types({"health_check_interval": 30, "ssl": True})
|
|
assert result["health_check_interval"] == 30
|
|
assert result["ssl"] is True
|
|
|
|
|
|
def test_health_check_interval_from_env_is_int(monkeypatch):
|
|
monkeypatch.setenv("REDIS_HOST", "localhost")
|
|
monkeypatch.setenv("REDIS_HEALTH_CHECK_INTERVAL", "30")
|
|
|
|
pool = get_redis_connection_pool()
|
|
|
|
assert pool is not None
|
|
interval = pool.connection_kwargs.get("health_check_interval")
|
|
assert interval == 30
|
|
assert isinstance(interval, int), f"Expected int, got {type(interval)}: {interval!r}"
|
|
|
|
|
|
def _signature_without_defaults(testkey):
|
|
"""Stand-in for a client whose parameter declares no default at all."""
|
|
|
|
|
|
def _signature_with_float_default(myparam=1.0):
|
|
"""Stand-in for a client whose parameter declares a float default."""
|
|
|
|
|
|
def test_coerce_redis_kwargs_types_empty_default_param_unchanged():
|
|
"""String params whose signature entry has no default (inspect.Parameter.empty) are left as-is."""
|
|
result = _coerce_redis_kwargs_types({"testkey": "some_value"}, client=_signature_without_defaults)
|
|
|
|
assert result["testkey"] == "some_value"
|
|
assert isinstance(result["testkey"], str)
|
|
|
|
|
|
def test_coerce_redis_kwargs_types_float_valid():
|
|
"""String values for params whose signature default is a float are coerced to float."""
|
|
result = _coerce_redis_kwargs_types({"myparam": "3.14"}, client=_signature_with_float_default)
|
|
|
|
assert result["myparam"] == pytest.approx(3.14)
|
|
assert isinstance(result["myparam"], float)
|
|
|
|
|
|
def test_coerce_redis_kwargs_types_float_invalid_drops_key():
|
|
"""An unconvertible string for a float-default param is dropped from the result."""
|
|
result = _coerce_redis_kwargs_types({"myparam": "not_a_float"}, client=_signature_with_float_default)
|
|
|
|
assert "myparam" not in result
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("raw", "expected"),
|
|
[("false", False), ("true", True), ("0", False), ("1", True)],
|
|
)
|
|
def test_coerce_socket_keepalive_string(raw, expected):
|
|
"""socket_keepalive's signature default is None, so it needs an explicit bool
|
|
coercion: a leftover "false" string is truthy and enables keepalive."""
|
|
result = _coerce_redis_kwargs_types({"socket_keepalive": raw})
|
|
|
|
assert result["socket_keepalive"] is expected
|
|
|
|
|
|
def test_get_redis_client_logic_coerces_cluster_only_kwargs(monkeypatch):
|
|
"""Cluster-only kwargs (absent from redis.Redis's signature) must still be
|
|
coerced when routing to a cluster, or Helm-stringified values reach
|
|
RedisCluster as strings."""
|
|
for envvar in (*_get_redis_env_kwarg_mapping(), "REDIS_CLUSTER_NODES", "REDIS_SENTINEL_NODES"):
|
|
monkeypatch.delenv(envvar, raising=False)
|
|
|
|
result = _get_redis_client_logic(
|
|
startup_nodes='[{"host": "localhost", "port": 7000}]',
|
|
cluster_error_retry_attempts="5",
|
|
require_full_coverage="false",
|
|
health_check_interval="30",
|
|
)
|
|
|
|
assert result["cluster_error_retry_attempts"] == 5
|
|
assert isinstance(result["cluster_error_retry_attempts"], int)
|
|
assert result["require_full_coverage"] is False
|
|
assert result["health_check_interval"] == 30
|
|
assert isinstance(result["health_check_interval"], int)
|
|
|
|
|
|
def test_get_redis_client_logic_raises_without_host_or_url(monkeypatch):
|
|
"""_get_redis_client_logic raises ValueError when neither host nor url is provided."""
|
|
for envvar in (*_get_redis_env_kwarg_mapping(), "REDIS_CLUSTER_NODES", "REDIS_SENTINEL_NODES"):
|
|
monkeypatch.delenv(envvar, raising=False)
|
|
|
|
with pytest.raises(ValueError, match="Either 'host' or 'url' must be specified for redis"):
|
|
_get_redis_client_logic()
|