Skip to content

Commit 12f0236

Browse files
codingjoeCopilot
andauthored
Redis client backport (#583)
Backprot of 52b05af --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
1 parent bcb2858 commit 12f0236

File tree

8 files changed

+196
-239
lines changed

8 files changed

+196
-239
lines changed

.github/workflows/ci.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,24 @@ jobs:
6161
ports:
6262
- 6379:6379
6363
options: --entrypoint redis-server
64+
redis-sentinel:
65+
image: bitnami/redis-sentinel:latest
66+
ports:
67+
- 26379:26379
68+
env:
69+
REDIS_MASTER_HOST: redis
70+
REDIS_MASTER_SET: mymaster
71+
REDIS_SENTINEL_QUORUM: 1
6472
rabbitmq:
6573
image: rabbitmq:3-management
6674
ports:
6775
- 5672:5672
6876
- 15672:15672
6977
env:
7078
REDIS_URL: redis://localhost:6379/0
79+
REDIS_SENTINEL_URL: redis-sentinel://localhost:26379/mymaster
80+
REDIS_SENTINEL_NODES: localhost:26379
81+
REDIS_SENTINEL_SERVICE_NAME: mymaster
7182
RABBITMQ_URL: amqp://guest:guest@localhost:5672//
7283
runs-on: ubuntu-latest
7384
strategy:

docs/install.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Add a health check view to your URL configuration. For example:
2424
# urls.py
2525
from django.urls import include, path
2626
from health_check.views import HealthCheckView
27+
from redis import Redis
2728

2829
urlpatterns = [
2930
#
@@ -45,8 +46,14 @@ urlpatterns = [
4546
"health_check.Storage",
4647
# 3rd party checks
4748
"health_check.contrib.celery.Ping",
48-
"health_check.contrib.rabbitmq.RabbitMQ",
49-
"health_check.contrib.redis.Redis",
49+
( # tuple with options
50+
"health_check.contrib.rabbitmq.RabbitMQ",
51+
{"amqp_url": "amqp://guest:guest@localhost:5672//"},
52+
),
53+
(
54+
"health_check.contrib.redis.Redis",
55+
{"client": Redis.from_url("redis://localhost:6379")},
56+
),
5057
],
5158
use_threading=True, # optional, default is True
5259
warnings_as_errors=True, # optional, default is True

docs/migrate-to-v4.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
> [!IMPORTANT]
44
> Version 3.21 started deprecation of settings and various old checks and APIs.
5-
> However, version 3.21 supports BOTH the OLD and NEW way of configuring health checks to ease the migration.
5+
> However, versions >=3.21 support BOTH the OLD and NEW way of configuring health checks to ease the migration.
66
77
1. If you have `health_check.db` in your `INSTALLED_APPS`, remove revert the migration to drop the `TestModel` table:
88

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import dataclasses
22
import logging
33
import typing
4+
import warnings
45

56
from django.conf import settings
6-
from redis import exceptions, from_url
7+
from redis import Redis as RedisClient
8+
from redis import RedisCluster, exceptions
79

810
from health_check.backends import HealthCheck
911
from health_check.exceptions import ServiceUnavailable
@@ -14,37 +16,60 @@
1416
@dataclasses.dataclass
1517
class RedisHealthCheck(HealthCheck):
1618
"""
17-
Check Redis service by pinging the redis instance with a redis connection.
19+
Check Redis service by pinging a Redis client.
20+
21+
This check works with any Redis client that implements the ping() method,
22+
including standard Redis, Sentinel, and Cluster clients.
1823
1924
Args:
20-
redis_url: The Redis connection URL.
21-
redis_url_options: Additional options for the Redis connection.
25+
client: A Redis client instance (Redis, Sentinel master, or Cluster).
26+
If provided, this takes precedence over redis_url.
27+
28+
Examples:
29+
Using a standard Redis client:
30+
>>> from redis import Redis as RedisClient
31+
>>> Redis(client=RedisClient(host='localhost', port=6379))
32+
33+
Using a Cluster client:
34+
>>> from redis.cluster import RedisCluster
35+
>>> Redis(client=RedisCluster(host='localhost', port=7000))
36+
37+
Using a Sentinel client:
38+
>>> from redis.sentinel import Sentinel
39+
>>> sentinel = Sentinel([('localhost', 26379)])
40+
>>> Redis(client=sentinel.master_for('mymaster'))
2241
2342
"""
2443

44+
client: RedisClient | RedisCluster = dataclasses.field(default=None, repr=False)
2545
redis_url: str = dataclasses.field(default=getattr(settings, "REDIS_URL", "redis://localhost/1"), repr=False)
2646
redis_url_options: dict[str, typing.Any] = dataclasses.field(
27-
default=getattr(settings, "HEALTHCHECK_REDIS_URL_OPTIONS", None), repr=False
47+
default_factory=lambda: getattr(settings, "HEALTHCHECK_REDIS_URL_OPTIONS", {}), repr=False
2848
)
2949

30-
def check_status(self):
31-
logger.debug("Got %s as the redis_url. Connecting to redis...", self.redis_url)
50+
def __post_init__(self):
51+
if not self.client:
52+
print(self.client)
53+
warnings.warn(
54+
"The 'redis_url' parameter is deprecated. Please use the 'client' parameter instead.",
55+
DeprecationWarning,
56+
stacklevel=2,
57+
)
58+
self.client = RedisClient.from_url(self.redis_url, **self.redis_url_options)
3259

33-
logger.debug("Attempting to connect to redis...")
60+
def check_status(self):
61+
logger.debug("Pinging Redis client...")
3462
try:
35-
# conn is used as a context to release opened resources later
36-
with from_url(self.redis_url, **(self.redis_url_options or {})) as conn:
37-
conn.ping() # exceptions may be raised upon ping
63+
self.client.ping()
3864
except ConnectionRefusedError as e:
39-
self.add_error(
40-
ServiceUnavailable("Unable to connect to Redis: Connection was refused."),
41-
e,
42-
)
65+
raise ServiceUnavailable("Unable to connect to Redis: Connection was refused.") from e
4366
except exceptions.TimeoutError as e:
44-
self.add_error(ServiceUnavailable("Unable to connect to Redis: Timeout."), e)
67+
raise ServiceUnavailable("Unable to connect to Redis: Timeout.") from e
4568
except exceptions.ConnectionError as e:
46-
self.add_error(ServiceUnavailable("Unable to connect to Redis: Connection Error"), e)
69+
raise ServiceUnavailable("Unable to connect to Redis: Connection Error") from e
4770
except BaseException as e:
48-
self.add_error(ServiceUnavailable("Unknown error"), e)
71+
raise ServiceUnavailable("Unknown error.") from e
4972
else:
5073
logger.debug("Connection established. Redis is healthy.")
74+
finally:
75+
self.client.close()

tests/contrib/test_redis.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Tests for Redis health check."""
2+
3+
import os
4+
from unittest import mock
5+
6+
import pytest
7+
8+
pytest.importorskip("redis")
9+
10+
from redis.exceptions import ConnectionError as RedisConnectionError
11+
from redis.exceptions import TimeoutError as RedisTimeoutError
12+
13+
from health_check.contrib.redis import Redis as RedisHealthCheck
14+
from health_check.exceptions import ServiceUnavailable
15+
16+
17+
class TestRedis:
18+
"""Test Redis health check."""
19+
20+
def test_redis__ok(self):
21+
"""Ping Redis successfully when using client parameter."""
22+
mock_client = mock.MagicMock()
23+
mock_client.ping.return_value = True
24+
25+
check = RedisHealthCheck(client=mock_client)
26+
check.check_status()
27+
assert check.errors == []
28+
mock_client.ping.assert_called_once()
29+
30+
def test_redis__connection_refused(self):
31+
"""Raise ServiceUnavailable when connection is refused."""
32+
mock_client = mock.MagicMock()
33+
mock_client.ping.side_effect = ConnectionRefusedError("refused")
34+
35+
check = RedisHealthCheck(client=mock_client)
36+
with pytest.raises(ServiceUnavailable):
37+
check.check_status()
38+
39+
def test_redis__timeout(self):
40+
"""Raise ServiceUnavailable when connection times out."""
41+
mock_client = mock.MagicMock()
42+
mock_client.ping.side_effect = RedisTimeoutError("timeout")
43+
44+
check = RedisHealthCheck(client=mock_client)
45+
with pytest.raises(ServiceUnavailable):
46+
check.check_status()
47+
48+
def test_redis__connection_error(self):
49+
"""Raise ServiceUnavailable when connection fails."""
50+
mock_client = mock.MagicMock()
51+
mock_client.ping.side_effect = RedisConnectionError("connection error")
52+
53+
check = RedisHealthCheck(client=mock_client)
54+
with pytest.raises(ServiceUnavailable):
55+
check.check_status()
56+
57+
def test_redis__unknown_error(self):
58+
"""Raise ServiceUnavailable for unexpected exceptions."""
59+
mock_client = mock.MagicMock()
60+
mock_client.ping.side_effect = RuntimeError("unexpected")
61+
62+
check = RedisHealthCheck(client=mock_client)
63+
with pytest.raises(ServiceUnavailable):
64+
check.check_status()
65+
66+
@pytest.mark.integration
67+
def test_redis__deprecated_url(self):
68+
"""Create client from URL when redis_url is provided."""
69+
redis_url = os.getenv("REDIS_URL")
70+
if not redis_url:
71+
pytest.skip("REDIS_URL not set; skipping integration test")
72+
73+
with pytest.warns(DeprecationWarning, match="redis_url.*deprecated"):
74+
check = RedisHealthCheck(redis_url="redis://localhost:6379")
75+
check.check_status()
76+
assert check.errors == []
77+
78+
@pytest.mark.integration
79+
def test_redis__deprecated_url_with_options(self):
80+
"""Pass options when creating client from URL."""
81+
redis_url = os.getenv("REDIS_URL")
82+
if not redis_url:
83+
pytest.skip("REDIS_URL not set; skipping integration test")
84+
85+
options = {"socket_connect_timeout": 5}
86+
with pytest.warns(DeprecationWarning):
87+
check = RedisHealthCheck(redis_url="redis://localhost:6379", redis_url_options=options)
88+
check.check_status()
89+
assert check.errors == []
90+
91+
@pytest.mark.integration
92+
def test_redis__real_connection(self):
93+
"""Ping real Redis server when REDIS_URL is configured."""
94+
redis_url = os.getenv("REDIS_URL")
95+
if not redis_url:
96+
pytest.skip("REDIS_URL not set; skipping integration test")
97+
98+
from redis import Redis as RedisClient
99+
100+
client = RedisClient.from_url(redis_url)
101+
check = RedisHealthCheck(client=client)
102+
check.check_status()
103+
assert check.errors == []
104+
client.close()
105+
106+
@pytest.mark.integration
107+
def test_redis__real_sentinel(self):
108+
"""Ping real Redis Sentinel when configured."""
109+
sentinel_url = os.getenv("REDIS_SENTINEL_URL")
110+
if not sentinel_url:
111+
pytest.skip("REDIS_SENTINEL_URL not set; skipping integration test")
112+
113+
from redis.sentinel import Sentinel
114+
115+
# Parse sentinel configuration from environment
116+
sentinel_nodes = os.getenv("REDIS_SENTINEL_NODES", "localhost:26379")
117+
service_name = os.getenv("REDIS_SENTINEL_SERVICE_NAME", "mymaster")
118+
119+
# Parse sentinel nodes from comma-separated list
120+
sentinels = []
121+
for node in sentinel_nodes.split(","):
122+
host, port = node.strip().split(":")
123+
sentinels.append((host, int(port)))
124+
125+
# Create Sentinel and get master client
126+
sentinel = Sentinel(sentinels)
127+
master = sentinel.master_for(service_name)
128+
129+
# Use the unified Redis check with the master client
130+
check = RedisHealthCheck(client=master)
131+
check.check_status()
132+
assert check.errors == []

tests/integration/test_redis_integration.py

Lines changed: 0 additions & 19 deletions
This file was deleted.

tests/test_redis.py

Lines changed: 0 additions & 94 deletions
This file was deleted.

0 commit comments

Comments
 (0)