From 95e083c664a7f05cb7e75675f481213059cc18f6 Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:46:13 +0300 Subject: [PATCH] feat: add an opt-in write probe to the health checks check_async_redis_health and check_redis_health now take a keyword-only write_key. Given one, the check still pings first and then runs SET 1 EX 60, so a read-only replica and a primary at maxmemory under noeviction -- both of which answer PONG and refuse every write -- come back False instead of True. ReadOnlyError, OutOfMemoryError and anything else the write raises go through the branch that already turns connection trouble into False plus a warning, so the check still never raises. Without write_key nothing changes: the check is the PING it has always been. The key is the caller's to name and to prefix, since this package applies key_prefix to nothing, and it is left to expire rather than deleted. --- README.md | 5 + docs/agents.md | 40 +++++-- docs/guide/advanced.md | 10 ++ docs/guide/quickstart.md | 18 +++ redis_client_kit/aio/lifecycle.py | 20 +++- redis_client_kit/sync/lifecycle.py | 20 +++- redis_client_kit/utils.py | 3 + tests/integration/conftest.py | 61 +++++++++- tests/integration/test_redis_lifecycle.py | 134 +++++++++++++++++++++- tests/unit/aio/test_client.py | 84 +++++++++++++- tests/unit/sync/test_sync_client.py | 78 +++++++++++++ 11 files changed, 455 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 106cd80..2da3c92 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,11 @@ from redis_client_kit import check_async_redis_health is_healthy = await check_async_redis_health(client) if is_healthy: print("Redis is ready!") + +# PING alone says healthy for a read-only replica and for a primary that is out of memory +# under noeviction. write_key adds a SET 1 EX 60 after the ping, for a service that +# needs Redis for more than reads. +is_healthy = await check_async_redis_health(client, write_key="myapp:health") ``` ### Graceful Shutdown diff --git a/docs/agents.md b/docs/agents.md index 74d5220..d700373 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -60,8 +60,9 @@ Four nouns and one direction of travel. * **Metrics** — anything satisfying `RedisMetricsProtocol` (`record_command`, `record_error`, `record_pool_stats`). `RedisMetrics` from the `metrics` extra is a Prometheus implementation of it. -* **Lifecycle** — `check_*_redis_health(client)` pings and returns a bool; - `close_*_redis_client(client)` closes and swallows. Neither raises. +* **Lifecycle** — `check_*_redis_health(client)` pings and returns a bool, and with + `write_key=` writes that key as well; `close_*_redis_client(client)` closes and + swallows. Neither raises. Nothing here holds state of its own. The client is the state, and it is `redis-py`'s. @@ -201,8 +202,8 @@ Everything in this table is importable from `redis_client_kit` itself. |---|---|---| | `create_async_redis_client` | `(settings, metrics=None)` | `Redis | RedisCluster`, instrumented when `metrics` is given | | `create_redis_client` | `(settings, metrics=None)` | the sync equivalent | -| `check_async_redis_health` | `await (client)` | `bool` — never raises | -| `check_redis_health` | `(client)` | `bool` — never raises | +| `check_async_redis_health` | `await (client, write_key=None)` | `bool` — never raises; `write_key` adds `SET 1 EX 60` after the ping | +| `check_redis_health` | `(client, write_key=None)` | the sync equivalent | | `close_async_redis_client` | `await (client)` | `None` — shielded, 10 s timeout, never raises | | `close_redis_client` | `(client)` | `None` — never raises | | `build_base_redis_kwargs` | `(settings, asyncio=False)` | `dict[str, object]` of `redis-py` keyword arguments; `asyncio=True` for a `redis.asyncio` client | @@ -222,7 +223,7 @@ The rest lives one import deeper. | `redis_client_kit.sync` | — | `SyncRedisClient`, `InstrumentedRedis`, `InstrumentedRedisCluster`, `create_redis_client`, `check_redis_health`, `close_redis_client` | | `redis_client_kit.config` | — | `RedisSettingsProtocol` and its parts: `RedisConnectionProtocol`, `RedisClusterProtocol`, `RedisPoolProtocol`, `RedisRetryProtocol`, `RedisSSLProtocol`, `RedisResponseProtocol` | | `redis_client_kit.protocols` | — | `RedisMetricsProtocol` | -| `redis_client_kit.utils` | — | the three exported helpers, plus `mask_redis_kwargs(kwargs)` for logging | +| `redis_client_kit.utils` | — | the three exported helpers, plus `mask_redis_kwargs(kwargs)` for logging and `WRITE_PROBE_TTL_S`, the write probe's expiry in seconds | | `redis_client_kit.settings` | `settings` | `BaseRedisSettings`, `RedisConnectionSettings`, `RedisClusterSettings`, `RedisPoolSettings`, `RedisRetrySettings`, `RedisSSLSettings`, `RedisResponseSettings` | | `redis_client_kit.metrics` | `metrics` | `RedisMetrics`, `REDIS_COMMAND_DURATION_BUCKETS` | | `redis_client_kit.providers` | `providers` | `AsyncRedisProvider(check_health_on_startup=True, provide_default_metrics=True)` | @@ -328,9 +329,18 @@ See rules 15 to 17. `connection=RedisConnectionSettings(host="…")`. 9. **`health_check_interval` is `redis-py`'s per-connection ping interval**, not the `check_*_redis_health` function. `0` and `None` both disable it. -10. **The health check never raises and never says "maybe".** It returns `False` on any - error and logs it. A cluster `ping()` returns one entry per node, and the check is - `True` only when every node answered truthily. +10. **The health check never raises and never says "maybe", and by default it only + pings.** It returns `False` on any error and logs it. A cluster `ping()` returns one + entry per node, and the check is `True` only when every node answered truthily. But + `PING` is a liveness answer, not a readiness one: a read-only replica and a server at + `maxmemory` under `noeviction` both reply `PONG` and refuse every write. Pass + `write_key="myapp:health"` and the check runs `SET 1 EX 60` + (`WRITE_PROBE_TTL_S`) after the ping, returning `False` on `ReadOnlyError`, + `OutOfMemoryError` or anything else the write raises. The key is yours to name and + yours to prefix — `key_prefix` is applied to nothing (rule 7) — and it is left to + expire rather than deleted. It costs one more round trip under the same + `socket_timeout`, and on a cluster the write reaches only the node owning that key's + slot, so `True` there means every node answered and one of them took a write. 11. **Closing never raises either.** `close_async_redis_client` shields `aclose()` and gives it 10 seconds (`ACLOSE_TIMEOUT_S`); a timeout or a broken close is a warning in the log, not an exception. Only `CancelledError` and `KeyboardInterrupt` propagate. @@ -436,6 +446,16 @@ if not await check_async_redis_health(client): raise RuntimeError("Redis is not answering") ``` +```python +# WRONG — readiness for a service that writes, from a check a read-only replica passes +if not await check_async_redis_health(client): + return Response("unready", status_code=503) + +# RIGHT — ask for the write you are going to need +if not await check_async_redis_health(client, write_key="myapp:health"): + return Response("unready", status_code=503) +``` + ```python # WRONG — a per-request client, and a close that can take the request down with it async def handler(settings): @@ -467,7 +487,9 @@ Python, by Pydantic or by `redis-py`. | `ImportError` from an optional submodule | the extra is not installed; the message names it | `check_*_redis_health` and `close_*_redis_client` convert `redis-py`'s errors into a -`False` and a log line respectively — they are the two places that swallow. +`False` and a log line respectively — they are the two places that swallow. A refused +write probe goes the same way: `ReadOnlyError` and `OutOfMemoryError` come back as `False` +with a warning in the log, not as an exception. ## Documentation map diff --git a/docs/guide/advanced.md b/docs/guide/advanced.md index ed030d1..e0bd963 100644 --- a/docs/guide/advanced.md +++ b/docs/guide/advanced.md @@ -240,6 +240,16 @@ is_healthy = await check_async_redis_health(client) # Returns True only if all nodes respond ``` +The [write probe](quickstart.md#health-checks) works here too, with one caveat: the ping +still reaches every node, but `SET` reaches only the node that owns that key's slot. + +```python +is_healthy = await check_async_redis_health(client, write_key="myapp:health") +``` + +`True` means every node answered the ping and one of them accepted a write. To cover the +writability of every slot you would need a key per slot, which this check does not do. + ### Read from Replicas Enable reading from replicas for read-heavy workloads: diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index aab5627..f49a068 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -95,6 +95,24 @@ else: print("Redis is not available") ``` +That is a `PING`, and `PING` answers `PONG` from a server that cannot take a write: a +read-only replica that a failover or a DNS mistake pointed you at, or a primary at +`maxmemory` under `noeviction`. Both are healthy for a reader and down for everyone else. +If your service needs Redis for more than reads, hand the check a key and it writes too: + +```python +is_healthy = await check_async_redis_health(client, write_key="myapp:health") +``` + +The write is `SET 1 EX 60` (`WRITE_PROBE_TTL_S` in `redis_client_kit.utils`), +run after the ping and only if the ping answered. The key is left to expire on its own. +Name it yourself, prefix included — this library applies `key_prefix` to nothing. The +default is unchanged: no `write_key`, no write. `check_redis_health` takes the same +argument. + +The cost is one more round trip, under the same `socket_timeout`, and on a cluster the +write reaches only the node that owns that key's slot — the ping still covers every node. + ## Graceful Shutdown Always close the client properly: diff --git a/redis_client_kit/aio/lifecycle.py b/redis_client_kit/aio/lifecycle.py index 163cd81..95aec5a 100644 --- a/redis_client_kit/aio/lifecycle.py +++ b/redis_client_kit/aio/lifecycle.py @@ -8,6 +8,7 @@ from redis.exceptions import RedisClusterException, RedisError +from ..utils import WRITE_PROBE_TTL_S from .types import AsyncRedisClient logger = logging.getLogger(__name__) @@ -36,11 +37,19 @@ async def close_async_redis_client(client: AsyncRedisClient) -> None: logger.info("Closed Redis client") -async def check_async_redis_health(client: AsyncRedisClient) -> bool: +async def check_async_redis_health(client: AsyncRedisClient, *, write_key: str | None = None) -> bool: """Check async Redis connection health. + Pings the server, and with ``write_key`` writes that key as well. A ping alone is a + liveness answer: a read-only replica and a server at ``maxmemory`` under ``noeviction`` + both reply PONG and refuse every write. The write probe turns that into a readiness + answer for a service that needs Redis for more than reads. + Args: client: Redis client (single or cluster) to check + write_key: Key to ``SET`` after the ping, expiring after ``WRITE_PROBE_TTL_S`` + seconds. None, the default, pings only. Prefix it yourself; this package + applies no key prefix. Returns: True if Redis is healthy, False otherwise @@ -50,9 +59,12 @@ async def check_async_redis_health(client: AsyncRedisClient) -> bool: # Redis Cluster ping returns dict[str, bool] (node_id -> success); single node returns bool. if isinstance(result, dict) and result and all(isinstance(k, str) for k in result): - return all(bool(v) for v in result.values()) + healthy = all(bool(v) for v in result.values()) + else: + healthy = bool(result) - return bool(result) + if healthy and write_key is not None: + healthy = bool(await client.set(write_key, "1", ex=WRITE_PROBE_TTL_S)) except (RedisError, RedisClusterException, OSError, ConnectionError, TimeoutError) as e: logger.warning( "Async Redis health check failed", @@ -66,6 +78,8 @@ async def check_async_redis_health(client: AsyncRedisClient) -> bool: # We return False to indicate unhealthy state but log the full exception for debugging. logger.exception("Unexpected error during Redis health check") return False + else: + return healthy __all__ = ["check_async_redis_health", "close_async_redis_client"] diff --git a/redis_client_kit/sync/lifecycle.py b/redis_client_kit/sync/lifecycle.py index 6470b2d..45734b5 100644 --- a/redis_client_kit/sync/lifecycle.py +++ b/redis_client_kit/sync/lifecycle.py @@ -7,6 +7,7 @@ from redis.exceptions import RedisClusterException, RedisError +from ..utils import WRITE_PROBE_TTL_S from .types import SyncRedisClient logger = logging.getLogger(__name__) @@ -28,11 +29,19 @@ def close_redis_client(client: SyncRedisClient) -> None: logger.info("Closed Redis client") -def check_redis_health(client: SyncRedisClient) -> bool: +def check_redis_health(client: SyncRedisClient, *, write_key: str | None = None) -> bool: """Check sync Redis connection health. + Pings the server, and with ``write_key`` writes that key as well. A ping alone is a + liveness answer: a read-only replica and a server at ``maxmemory`` under ``noeviction`` + both reply PONG and refuse every write. The write probe turns that into a readiness + answer for a service that needs Redis for more than reads. + Args: client: Redis client (single or cluster) to check + write_key: Key to ``SET`` after the ping, expiring after ``WRITE_PROBE_TTL_S`` + seconds. None, the default, pings only. Prefix it yourself; this package + applies no key prefix. Returns: True if Redis is healthy, False otherwise @@ -42,9 +51,12 @@ def check_redis_health(client: SyncRedisClient) -> bool: # Redis Cluster ping returns dict[str, bool] (node_id -> success); single node returns bool. if isinstance(result, dict) and result and all(isinstance(k, str) for k in result): - return all(bool(v) for v in result.values()) + healthy = all(bool(v) for v in result.values()) + else: + healthy = bool(result) - return bool(result) + if healthy and write_key is not None: + healthy = bool(client.set(write_key, "1", ex=WRITE_PROBE_TTL_S)) except (RedisError, RedisClusterException, OSError, ConnectionError, TimeoutError) as e: logger.warning( "Redis health check failed", @@ -58,6 +70,8 @@ def check_redis_health(client: SyncRedisClient) -> bool: # We return False to indicate unhealthy state but log the full exception for debugging. logger.exception("Unexpected error during Redis health check") return False + else: + return healthy __all__ = ["check_redis_health", "close_redis_client"] diff --git a/redis_client_kit/utils.py b/redis_client_kit/utils.py index bbcb4c8..f87ef23 100644 --- a/redis_client_kit/utils.py +++ b/redis_client_kit/utils.py @@ -12,6 +12,8 @@ from .config import RedisSettingsProtocol +WRITE_PROBE_TTL_S = 60 + def parse_redis_url_node(node: str) -> tuple[str, int]: """Parse Redis node from host:port, [ipv6]:port or redis:// URL string.""" @@ -143,6 +145,7 @@ def mask_redis_kwargs(kwargs: dict[str, object]) -> dict[str, object]: __all__ = [ + "WRITE_PROBE_TTL_S", "build_base_redis_kwargs", "build_redis_retry", "mask_redis_kwargs", diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1885dca..5111f5a 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -3,6 +3,8 @@ Automatically applies pytest.mark.integration to all tests in integration/ directory. """ +from collections.abc import Iterator + try: import docker from docker.errors import DockerException @@ -11,10 +13,18 @@ DockerException = Exception # type: ignore[assignment, misc] import pytest +from redis import Redis +from redis.exceptions import OutOfMemoryError +from testcontainers.core.container import DockerContainer +from testcontainers.core.network import Network +from testcontainers.core.wait_strategies import LogMessageWaitStrategy from testcontainers.redis import RedisContainer from redis_client_kit import RedisSettingsProtocol +REDIS_IMAGE = "redis:7-alpine" +REDIS_PORT = 6379 + def is_docker_available() -> bool: """Check if docker is available to run integration tests.""" @@ -117,7 +127,7 @@ def __init__(self) -> None: @pytest.fixture(scope="module") def redis_container() -> RedisContainer: """Provide a Redis container for integration tests.""" - with RedisContainer("redis:7-alpine") as redis: + with RedisContainer(REDIS_IMAGE) as redis: yield redis @@ -125,3 +135,52 @@ def redis_container() -> RedisContainer: def fake_redis_settings() -> FakeRedisSettings: """Provide fake Redis settings for testing.""" return FakeRedisSettings() + + +# Servers that answer PING and refuse writes + + +@pytest.fixture(scope="module") +def full_noeviction_redis() -> Iterator[DockerContainer]: + """A primary capped at 1 MB under ``noeviction``, filled until it refuses the next write.""" + container = ( + DockerContainer(REDIS_IMAGE) + .with_exposed_ports(REDIS_PORT) + .with_command("redis-server --maxmemory 1mb --maxmemory-policy noeviction") + .waiting_for(LogMessageWaitStrategy("Ready to accept connections")) + ) + with container: + fill_until_full(container) + yield container + + +@pytest.fixture(scope="module") +def read_only_replica() -> Iterator[DockerContainer]: + """A replica started as ``redis-server --replicaof primary 6379``, synced with a live primary.""" + with Network() as network: + primary = ( + DockerContainer(REDIS_IMAGE) + .with_exposed_ports(REDIS_PORT) + .with_network(network) + .with_network_aliases("primary") + ) + replica = ( + DockerContainer(REDIS_IMAGE) + .with_exposed_ports(REDIS_PORT) + .with_network(network) + .with_command(f"redis-server --replicaof primary {REDIS_PORT}") + .waiting_for(LogMessageWaitStrategy("MASTER <-> REPLICA sync: Finished with success")) + ) + with primary, replica: + yield replica + + +def fill_until_full(container: DockerContainer) -> None: + """Write 8 KB values until one is refused, so a test meets a server that is already full.""" + with Redis(host=container.get_container_host_ip(), port=int(container.get_exposed_port(REDIS_PORT))) as client: + for i in range(512): + try: + client.set(f"fill:{i}", "x" * 8192) + except OutOfMemoryError: + return + raise RuntimeError("Redis took 4 MB of writes under a 1 mb maxmemory cap") diff --git a/tests/integration/test_redis_lifecycle.py b/tests/integration/test_redis_lifecycle.py index 298709c..8dc4fe7 100644 --- a/tests/integration/test_redis_lifecycle.py +++ b/tests/integration/test_redis_lifecycle.py @@ -5,17 +5,20 @@ import pytest from redis.exceptions import TimeoutError as RedisTimeoutError +from testcontainers.core.container import DockerContainer from testcontainers.redis import RedisContainer from redis_client_kit import ( check_async_redis_health, + check_redis_health, close_async_redis_client, close_redis_client, create_async_redis_client, create_redis_client, ) +from redis_client_kit.utils import WRITE_PROBE_TTL_S -from .conftest import FakeRedisSettings, is_docker_available +from .conftest import REDIS_PORT, FakeRedisSettings, is_docker_available # Skip all tests in this module if docker is not available pytestmark = pytest.mark.skipif(not is_docker_available(), reason="Docker is not available") @@ -199,3 +202,132 @@ def test__sync_redis_client__retry_enabled_and_redis_paused__retries_before_rais # Assert - four attempts of 0.5 s plus 0.2 + 0.4 + 0.8 s of backoff, not a single attempt assert 3.0 <= elapsed < 6.0 + + +@pytest.mark.asyncio +async def test__async_health_check__write_key_on_a_healthy_server__returns_true_and_the_key_expires( + redis_container: RedisContainer, +) -> None: + # Arrange + settings = FakeRedisSettings() + settings.connection.host = redis_container.get_container_host_ip() + settings.connection.port = int(redis_container.get_exposed_port(redis_container.port)) + client = create_async_redis_client(settings) + + # Act + try: + is_healthy = await check_async_redis_health(client, write_key="test:health") + ttl = await client.ttl("test:health") + finally: + await close_async_redis_client(client) + + # Assert + assert is_healthy is True + assert 0 < ttl <= WRITE_PROBE_TTL_S + + +@pytest.mark.asyncio +async def test__async_health_check__read_only_replica__ping_says_healthy_and_the_write_probe_does_not( + read_only_replica: DockerContainer, +) -> None: + # Arrange + settings = FakeRedisSettings() + settings.connection.host = read_only_replica.get_container_host_ip() + settings.connection.port = int(read_only_replica.get_exposed_port(REDIS_PORT)) + client = create_async_redis_client(settings) + + # Act + try: + ping_only = await check_async_redis_health(client) + with_write_probe = await check_async_redis_health(client, write_key="test:health") + finally: + await close_async_redis_client(client) + + # Assert + assert ping_only is True + assert with_write_probe is False + + +@pytest.mark.asyncio +async def test__async_health_check__full_noeviction_server__ping_says_healthy_and_the_write_probe_does_not( + full_noeviction_redis: DockerContainer, +) -> None: + # Arrange + settings = FakeRedisSettings() + settings.connection.host = full_noeviction_redis.get_container_host_ip() + settings.connection.port = int(full_noeviction_redis.get_exposed_port(REDIS_PORT)) + client = create_async_redis_client(settings) + + # Act + try: + ping_only = await check_async_redis_health(client) + with_write_probe = await check_async_redis_health(client, write_key="test:health") + finally: + await close_async_redis_client(client) + + # Assert + assert ping_only is True + assert with_write_probe is False + + +def test__sync_health_check__write_key_on_a_healthy_server__returns_true_and_the_key_expires( + redis_container: RedisContainer, +) -> None: + # Arrange + settings = FakeRedisSettings() + settings.connection.host = redis_container.get_container_host_ip() + settings.connection.port = int(redis_container.get_exposed_port(redis_container.port)) + client = create_redis_client(settings) + + # Act + try: + is_healthy = check_redis_health(client, write_key="test:health") + ttl = client.ttl("test:health") + finally: + close_redis_client(client) + + # Assert + assert is_healthy is True + assert 0 < ttl <= WRITE_PROBE_TTL_S + + +def test__sync_health_check__read_only_replica__ping_says_healthy_and_the_write_probe_does_not( + read_only_replica: DockerContainer, +) -> None: + # Arrange + settings = FakeRedisSettings() + settings.connection.host = read_only_replica.get_container_host_ip() + settings.connection.port = int(read_only_replica.get_exposed_port(REDIS_PORT)) + client = create_redis_client(settings) + + # Act + try: + ping_only = check_redis_health(client) + with_write_probe = check_redis_health(client, write_key="test:health") + finally: + close_redis_client(client) + + # Assert + assert ping_only is True + assert with_write_probe is False + + +def test__sync_health_check__full_noeviction_server__ping_says_healthy_and_the_write_probe_does_not( + full_noeviction_redis: DockerContainer, +) -> None: + # Arrange + settings = FakeRedisSettings() + settings.connection.host = full_noeviction_redis.get_container_host_ip() + settings.connection.port = int(full_noeviction_redis.get_exposed_port(REDIS_PORT)) + client = create_redis_client(settings) + + # Act + try: + ping_only = check_redis_health(client) + with_write_probe = check_redis_health(client, write_key="test:health") + finally: + close_redis_client(client) + + # Assert + assert ping_only is True + assert with_write_probe is False diff --git a/tests/unit/aio/test_client.py b/tests/unit/aio/test_client.py index f92779e..577574a 100644 --- a/tests/unit/aio/test_client.py +++ b/tests/unit/aio/test_client.py @@ -5,13 +5,14 @@ from redis.asyncio.cluster import RedisCluster from redis.asyncio.retry import Retry from redis.backoff import NoBackoff -from redis.exceptions import RedisError +from redis.exceptions import OutOfMemoryError, ReadOnlyError, RedisError from redis_client_kit.aio import ( check_async_redis_health, close_async_redis_client, create_async_redis_client, ) +from redis_client_kit.utils import WRITE_PROBE_TTL_S @pytest.mark.parametrize( @@ -242,3 +243,84 @@ async def test__check_async_redis_health__cancelled_error__reraises_exception() # Act & Assert with pytest.raises(asyncio.CancelledError): await check_async_redis_health(mock_client) + + +@pytest.mark.asyncio +async def test__check_async_redis_health__no_write_key__pings_only() -> None: + # Arrange + mock_client = AsyncMock() + mock_client.ping.return_value = True + + # Act + result = await check_async_redis_health(mock_client) + + # Assert + assert result is True + mock_client.set.assert_not_awaited() + + +@pytest.mark.asyncio +async def test__check_async_redis_health__write_key__sets_it_with_a_ttl_after_the_ping() -> None: + # Arrange + mock_client = AsyncMock() + mock_client.ping.return_value = True + mock_client.set.return_value = True + + # Act + result = await check_async_redis_health(mock_client, write_key="myapp:health") + + # Assert + assert result is True + mock_client.ping.assert_awaited_once() + mock_client.set.assert_awaited_once_with("myapp:health", "1", ex=WRITE_PROBE_TTL_S) + + +@pytest.mark.parametrize( + "exception", + [ + ReadOnlyError("You can't write against a read only replica."), + OutOfMemoryError("command not allowed when used memory > 'maxmemory'."), + ], + ids=["read-only-replica", "out-of-memory"], +) +@pytest.mark.asyncio +async def test__check_async_redis_health__write_refused__returns_false(exception: Exception) -> None: + # Arrange + mock_client = AsyncMock() + mock_client.ping.return_value = True + mock_client.set.side_effect = exception + + # Act + result = await check_async_redis_health(mock_client, write_key="myapp:health") + + # Assert + assert result is False + + +@pytest.mark.asyncio +async def test__check_async_redis_health__failed_ping_with_write_key__does_not_write() -> None: + # Arrange + mock_client = AsyncMock() + mock_client.ping.return_value = False + + # Act + result = await check_async_redis_health(mock_client, write_key="myapp:health") + + # Assert + assert result is False + mock_client.set.assert_not_awaited() + + +@pytest.mark.asyncio +async def test__check_async_redis_health__cluster_with_write_key__pings_every_node_then_writes() -> None: + # Arrange + mock_client = AsyncMock(spec=RedisCluster) + mock_client.ping = AsyncMock(return_value={"node1": True, "node2": True}) + mock_client.set = AsyncMock(return_value=True) + + # Act + result = await check_async_redis_health(mock_client, write_key="myapp:health") + + # Assert + assert result is True + mock_client.set.assert_awaited_once_with("myapp:health", "1", ex=WRITE_PROBE_TTL_S) diff --git a/tests/unit/sync/test_sync_client.py b/tests/unit/sync/test_sync_client.py index d71b357..c530018 100644 --- a/tests/unit/sync/test_sync_client.py +++ b/tests/unit/sync/test_sync_client.py @@ -4,6 +4,7 @@ import pytest from redis.backoff import NoBackoff +from redis.exceptions import OutOfMemoryError, ReadOnlyError from redis.retry import Retry from redis_client_kit.sync import ( @@ -11,6 +12,7 @@ close_redis_client, create_redis_client, ) +from redis_client_kit.utils import WRITE_PROBE_TTL_S @pytest.mark.parametrize( @@ -189,3 +191,79 @@ def test__check_redis_health__exception_raised__returns_false(exception: Excepti # Assert assert result is False + + +def test__check_redis_health__no_write_key__pings_only() -> None: + # Arrange + mock_client = MagicMock() + mock_client.ping.return_value = True + + # Act + result = check_redis_health(mock_client) + + # Assert + assert result is True + mock_client.set.assert_not_called() + + +def test__check_redis_health__write_key__sets_it_with_a_ttl_after_the_ping() -> None: + # Arrange + mock_client = MagicMock() + mock_client.ping.return_value = True + mock_client.set.return_value = True + + # Act + result = check_redis_health(mock_client, write_key="myapp:health") + + # Assert + assert result is True + mock_client.ping.assert_called_once() + mock_client.set.assert_called_once_with("myapp:health", "1", ex=WRITE_PROBE_TTL_S) + + +@pytest.mark.parametrize( + "exception", + [ + ReadOnlyError("You can't write against a read only replica."), + OutOfMemoryError("command not allowed when used memory > 'maxmemory'."), + ], + ids=["read-only-replica", "out-of-memory"], +) +def test__check_redis_health__write_refused__returns_false(exception: Exception) -> None: + # Arrange + mock_client = MagicMock() + mock_client.ping.return_value = True + mock_client.set.side_effect = exception + + # Act + result = check_redis_health(mock_client, write_key="myapp:health") + + # Assert + assert result is False + + +def test__check_redis_health__failed_ping_with_write_key__does_not_write() -> None: + # Arrange + mock_client = MagicMock() + mock_client.ping.return_value = False + + # Act + result = check_redis_health(mock_client, write_key="myapp:health") + + # Assert + assert result is False + mock_client.set.assert_not_called() + + +def test__check_redis_health__cluster_with_write_key__pings_every_node_then_writes() -> None: + # Arrange + mock_client = MagicMock() + mock_client.ping.return_value = {"node1": True, "node2": True} + mock_client.set.return_value = True + + # Act + result = check_redis_health(mock_client, write_key="myapp:health") + + # Assert + assert result is True + mock_client.set.assert_called_once_with("myapp:health", "1", ex=WRITE_PROBE_TTL_S)