From 5fe1264291066c6c591cb0f45b31cae55a8f0c43 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Mon, 23 Feb 2026 09:50:09 +0100 Subject: [PATCH 1/3] Show host and db in Redis check __repr__ Fix #659 --- health_check/contrib/redis.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/health_check/contrib/redis.py b/health_check/contrib/redis.py index 39dc5f2b..0b81dc1b 100644 --- a/health_check/contrib/redis.py +++ b/health_check/contrib/redis.py @@ -53,6 +53,24 @@ class Redis(HealthCheck): dataclasses.field(repr=False, default=None) ) + def __repr__(self): + # include client host name and logical database number to identify them + # Create a new client for this health check request + if self.client_factory is not None: + client = self.client_factory() + else: + # Use the deprecated client parameter (user manages lifecycle) + client = self.client + + try: + conn_kwargs = client.connection_pool.connection_kwargs + host = conn_kwargs["host"] + db = conn_kwargs["db"] + return f"Redis(client=RedisClient(host={host}, db={db}))" + except (AttributeError, KeyError): + # If the client doesn't have connection_pool or connection_kwargs, fall back to default repr + return super().__repr__() + def __post_init__(self): # Validate that exactly one of client or client_factory is provided if self.client is not None and self.client_factory is not None: From 6c12d16bf150e52e7df711a23c89720b1f396c58 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:26:20 +0100 Subject: [PATCH 2/3] Show host and db in Redis check `__repr__` (with cluster support) (#663) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiple Redis health check instances are indistinguishable in health check output. This adds `host`, `db`, and cluster node information to `Redis.__repr__` so each instance is identifiable in JSON, text, and feed outputs. ## Changes - **`Redis.__repr__`**: Extracts `host` and `db` from `connection_pool.connection_kwargs` for standard Redis clients using EAFP; for `RedisCluster` clients, extracts startup node names (`host:port`) from `client.startup_nodes`; falls back to default dataclass repr for clients that don't expose these (e.g. Sentinel) - **Tests**: Eight new non-mocked tests covering standard client, `from_url`, deprecated `client` param, Sentinel fallback, RedisCluster with startup node hosts, and explicit security tests ensuring passwords and usernames are never exposed in the repr output ## Security Credentials are never included in the repr output. The implementation only extracts `host` and `db` keys explicitly from `connection_kwargs`, and uses `node.name` (`host:port`) for cluster nodes. Security regression tests enforce this for all client types: - Standard client with `password=` kwarg - Credentials embedded in a Redis URL (`redis://user:pass@host/db`) - `RedisCluster` with `password=` and `username=` kwargs ## Example ```python from redis.asyncio import Redis as RedisClient, RedisCluster from redis.asyncio.cluster import ClusterNode from health_check.contrib.redis import Redis check = Redis(client_factory=lambda: RedisClient.from_url("redis://localhost:6379/1")) repr(check) # "Redis(client=RedisClient(host=localhost, db=1))" # RedisCluster shows startup node host:port names cluster_check = Redis(client_factory=lambda: RedisCluster( startup_nodes=[ClusterNode("node1", 7000), ClusterNode("node2", 7001)] )) repr(cluster_check) # "Redis(client=RedisCluster(hosts=['node1:7000', 'node2:7001']))" # Sentinel falls back gracefully — host/db not accessible via connection_pool sentinel_check = Redis(client_factory=lambda: Sentinel([("localhost", 26379)]).master_for("mymaster")) repr(sentinel_check) # "Redis()" # Credentials are never included secret_check = Redis(client_factory=lambda: RedisClient.from_url("redis://admin:secret@host/0")) repr(secret_check) # "Redis(client=RedisClient(host=host, db=0))" — no credentials ``` --- 💬 We'd love your input! Share your thoughts on Copilot coding agent in our [2 minute survey](https://gh.io/copilot-coding-agent-survey). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- health_check/contrib/redis.py | 8 ++- tests/contrib/test_redis.py | 119 +++++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/health_check/contrib/redis.py b/health_check/contrib/redis.py index 0b81dc1b..dd987079 100644 --- a/health_check/contrib/redis.py +++ b/health_check/contrib/redis.py @@ -55,7 +55,6 @@ class Redis(HealthCheck): def __repr__(self): # include client host name and logical database number to identify them - # Create a new client for this health check request if self.client_factory is not None: client = self.client_factory() else: @@ -68,7 +67,12 @@ def __repr__(self): db = conn_kwargs["db"] return f"Redis(client=RedisClient(host={host}, db={db}))" except (AttributeError, KeyError): - # If the client doesn't have connection_pool or connection_kwargs, fall back to default repr + pass + + try: + hosts = [node.name for node in client.startup_nodes] + return f"Redis(client=RedisCluster(hosts={hosts!r}))" + except AttributeError: return super().__repr__() def __post_init__(self): diff --git a/tests/contrib/test_redis.py b/tests/contrib/test_redis.py index 825b7415..b16b88e8 100644 --- a/tests/contrib/test_redis.py +++ b/tests/contrib/test_redis.py @@ -149,7 +149,124 @@ async def test_redis__validation_neither_param(self): ): RedisHealthCheck() - @pytest.mark.integration + def test_redis__repr_standard_client(self): + """Verify repr includes host and db for a standard Redis client.""" + from redis.asyncio import Redis as RedisClient + + check = RedisHealthCheck( + client_factory=lambda: RedisClient(host="myhost", port=6379, db=2) + ) + assert repr(check) == "Redis(client=RedisClient(host=myhost, db=2))" + + def test_redis__repr_from_url(self): + """Verify repr includes host and db when client is created via from_url.""" + from redis.asyncio import Redis as RedisClient + + check = RedisHealthCheck( + client_factory=lambda: RedisClient.from_url( + "redis://cache.example.com:6379/3" + ) + ) + assert "host=cache.example.com" in repr(check), ( + "repr should include the host from the Redis URL" + ) + assert "db=3" in repr(check), "repr should include the db from the Redis URL" + + def test_redis__repr_deprecated_client(self): + """Verify repr includes host and db when using deprecated client parameter.""" + from redis.asyncio import Redis as RedisClient + + with pytest.warns(DeprecationWarning): + check = RedisHealthCheck( + client=RedisClient(host="oldhost", port=6379, db=5) + ) + assert "host=oldhost" in repr(check), ( + "repr should include the host from the deprecated client" + ) + assert "db=5" in repr(check), ( + "repr should include the db from the deprecated client" + ) + + def test_redis__repr_sentinel_client(self): + """Verify repr falls back gracefully for Sentinel clients without host/db.""" + from redis.asyncio import Sentinel + + check = RedisHealthCheck( + client_factory=lambda: Sentinel([("localhost", 26379)]).master_for( + "mymaster" + ) + ) + # Sentinel clients don't expose host/db in connection_pool.connection_kwargs + # __repr__ should fall back to the default dataclass repr without raising + assert repr(check) == "Redis()" + + def test_redis__repr_cluster_client(self): + """Verify repr includes startup node hosts for RedisCluster clients.""" + from redis.asyncio import RedisCluster + from redis.asyncio.cluster import ClusterNode + + check = RedisHealthCheck( + client_factory=lambda: RedisCluster( + startup_nodes=[ClusterNode("node1", 7000), ClusterNode("node2", 7001)] + ) + ) + assert "node1:7000" in repr(check), ( + "repr should include the first cluster node host:port" + ) + assert "node2:7001" in repr(check), ( + "repr should include the second cluster node host:port" + ) + + def test_redis__repr_excludes_password(self): + """Verify repr never leaks passwords for standard Redis clients.""" + from redis.asyncio import Redis as RedisClient + + check = RedisHealthCheck( + client_factory=lambda: RedisClient( + host="myhost", port=6379, db=0, password="supersecret" + ) + ) + assert "supersecret" not in repr(check), ( + "repr must never expose the Redis password" + ) + + def test_redis__repr_excludes_password_from_url(self): + """Verify repr never leaks passwords embedded in a Redis URL.""" + from redis.asyncio import Redis as RedisClient + + check = RedisHealthCheck( + client_factory=lambda: RedisClient.from_url( + "redis://admin:supersecret@cache.example.com:6379/3" + ) + ) + result = repr(check) + assert "supersecret" not in result, ( + "repr must never expose the password from a Redis URL" + ) + assert "admin" not in result, ( + "repr must never expose the username from a Redis URL" + ) + + def test_redis__repr_excludes_cluster_password(self): + """Verify repr never leaks passwords for RedisCluster clients.""" + from redis.asyncio import RedisCluster + from redis.asyncio.cluster import ClusterNode + + check = RedisHealthCheck( + client_factory=lambda: RedisCluster( + startup_nodes=[ClusterNode("node1", 7000)], + password="clusterpass", + username="clusteruser", + ) + ) + result = repr(check) + assert "clusterpass" not in result, ( + "repr must never expose the cluster password" + ) + assert "clusteruser" not in result, ( + "repr must never expose the cluster username" + ) + @pytest.mark.asyncio async def test_redis__real_connection(self): """Ping real Redis server when REDIS_URL is configured.""" From 7fefd041730458a068fc3bc67ae72bc32a5ef190 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Mon, 23 Feb 2026 12:30:36 +0100 Subject: [PATCH 3/3] Noqa --- tests/contrib/test_redis.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/contrib/test_redis.py b/tests/contrib/test_redis.py index b16b88e8..5d3e1c4e 100644 --- a/tests/contrib/test_redis.py +++ b/tests/contrib/test_redis.py @@ -223,7 +223,10 @@ def test_redis__repr_excludes_password(self): check = RedisHealthCheck( client_factory=lambda: RedisClient( - host="myhost", port=6379, db=0, password="supersecret" + host="myhost", + port=6379, + db=0, + password="supersecret", # noqa: S106 ) ) assert "supersecret" not in repr(check), ( @@ -255,7 +258,7 @@ def test_redis__repr_excludes_cluster_password(self): check = RedisHealthCheck( client_factory=lambda: RedisCluster( startup_nodes=[ClusterNode("node1", 7000)], - password="clusterpass", + password="clusterpass", # noqa: S106 username="clusteruser", ) )