Skip to content

Commit 27dabc0

Browse files
committed
feat: add Redis Sentinel URL scheme support (#213)
Add support for connecting to Redis through Sentinel using the redis+sentinel:// URL scheme. This enables high availability Redis deployments with automatic failover. URL format: redis+sentinel://[username:password@]host1:port1,host2:port2/service_name[/db] - Add Sentinel URL parsing in RedisConnectionFactory - Support both sync and async Redis connections via Sentinel - Add comprehensive unit tests for Sentinel URL handling Fixes #213
1 parent 2660e2f commit 27dabc0

File tree

2 files changed

+159
-3
lines changed

2 files changed

+159
-3
lines changed

redisvl/redis/connection.py

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
2-
from typing import Any, Dict, List, Optional, Type
2+
from typing import Any, Dict, List, Optional, Type, TypeVar, Union, overload
3+
from urllib.parse import urlparse
34
from warnings import warn
45

56
from redis import Redis, RedisCluster
@@ -11,6 +12,7 @@
1112
from redis.asyncio.connection import SSLConnection as AsyncSSLConnection
1213
from redis.connection import SSLConnection
1314
from redis.exceptions import ResponseError
15+
from redis.sentinel import Sentinel
1416

1517
from redisvl import __version__
1618
from redisvl.redis.constants import REDIS_URL_ENV_VAR
@@ -198,6 +200,9 @@ def parse_attrs(attrs, field_type=None):
198200
}
199201

200202

203+
T = TypeVar("T", Redis, AsyncRedis)
204+
205+
201206
class RedisConnectionFactory:
202207
"""Builds connections to a Redis database, supporting both synchronous and
203208
asynchronous clients.
@@ -259,7 +264,9 @@ def get_redis_connection(
259264
variable is not set.
260265
"""
261266
url = redis_url or get_address_from_env()
262-
if is_cluster_url(url, **kwargs):
267+
if url.startswith("redis+sentinel"):
268+
client = RedisConnectionFactory._redis_sentinel_client(url, Redis, **kwargs)
269+
elif is_cluster_url(url, **kwargs):
263270
client = RedisCluster.from_url(url, **kwargs)
264271
else:
265272
client = Redis.from_url(url, **kwargs)
@@ -299,7 +306,11 @@ async def _get_aredis_connection(
299306
"""
300307
url = url or get_address_from_env()
301308

302-
if is_cluster_url(url, **kwargs):
309+
if url.startswith("redis+sentinel"):
310+
client = RedisConnectionFactory._redis_sentinel_client(
311+
url, AsyncRedis, **kwargs
312+
)
313+
elif is_cluster_url(url, **kwargs):
303314
client = AsyncRedisCluster.from_url(url, **kwargs)
304315
else:
305316
client = AsyncRedis.from_url(url, **kwargs)
@@ -340,6 +351,10 @@ def get_async_redis_connection(
340351
DeprecationWarning,
341352
)
342353
url = url or get_address_from_env()
354+
if url.startswith("redis+sentinel"):
355+
return RedisConnectionFactory._redis_sentinel_client(
356+
url, AsyncRedis, **kwargs
357+
)
343358
return AsyncRedis.from_url(url, **kwargs)
344359

345360
@staticmethod
@@ -446,3 +461,60 @@ async def validate_async_redis(
446461
await redis_client.echo(_lib_name)
447462

448463
# Module validation removed - operations will fail naturally if modules are missing
464+
465+
@staticmethod
466+
@overload
467+
def _redis_sentinel_client(
468+
redis_url: str, redis_class: type[Redis], **kwargs: Any
469+
) -> Redis: ...
470+
471+
@staticmethod
472+
@overload
473+
def _redis_sentinel_client(
474+
redis_url: str, redis_class: type[AsyncRedis], **kwargs: Any
475+
) -> AsyncRedis: ...
476+
477+
@staticmethod
478+
def _redis_sentinel_client(
479+
redis_url: str, redis_class: Union[type[Redis], type[AsyncRedis]], **kwargs: Any
480+
) -> Union[Redis, AsyncRedis]:
481+
sentinel_list, service_name, db, username, password = (
482+
RedisConnectionFactory._parse_sentinel_url(redis_url)
483+
)
484+
485+
sentinel_kwargs = {}
486+
if username:
487+
sentinel_kwargs["username"] = username
488+
kwargs["username"] = username
489+
if password:
490+
sentinel_kwargs["password"] = password
491+
kwargs["password"] = password
492+
if db:
493+
kwargs["db"] = db
494+
495+
sentinel = Sentinel(sentinel_list, sentinel_kwargs=sentinel_kwargs, **kwargs)
496+
return sentinel.master_for(service_name, redis_class=redis_class, **kwargs)
497+
498+
@staticmethod
499+
def _parse_sentinel_url(url: str) -> tuple:
500+
parsed_url = urlparse(url)
501+
hosts_part = parsed_url.netloc.split("@")[-1]
502+
sentinel_hosts = hosts_part.split(",")
503+
504+
sentinel_list = []
505+
for host in sentinel_hosts:
506+
host_parts = host.split(":")
507+
if len(host_parts) == 2:
508+
sentinel_list.append((host_parts[0], int(host_parts[1])))
509+
else:
510+
sentinel_list.append((host_parts[0], 26379))
511+
512+
service_name = "mymaster"
513+
db = None
514+
if parsed_url.path:
515+
path_parts = parsed_url.path.split("/")
516+
service_name = path_parts[1] or "mymaster"
517+
if len(path_parts) > 2:
518+
db = path_parts[2]
519+
520+
return sentinel_list, service_name, db, parsed_url.username, parsed_url.password

tests/unit/test_sentinel_url.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
import pytest
4+
from redis.exceptions import ConnectionError
5+
6+
from redisvl.redis.connection import RedisConnectionFactory
7+
8+
9+
@pytest.mark.parametrize("use_async", [False, True])
10+
def test_sentinel_url_connection(use_async):
11+
sentinel_url = (
12+
"redis+sentinel://username:password@host1:26379,host2:26380/mymaster/0"
13+
)
14+
15+
with patch("redisvl.redis.connection.Sentinel") as mock_sentinel:
16+
mock_master = MagicMock()
17+
mock_sentinel.return_value.master_for.return_value = mock_master
18+
19+
if use_async:
20+
client = RedisConnectionFactory.get_async_redis_connection(sentinel_url)
21+
else:
22+
client = RedisConnectionFactory.get_redis_connection(sentinel_url)
23+
24+
mock_sentinel.assert_called_once()
25+
call_args = mock_sentinel.call_args
26+
assert call_args[0][0] == [("host1", 26379), ("host2", 26380)]
27+
assert call_args[1]["sentinel_kwargs"] == {
28+
"username": "username",
29+
"password": "password",
30+
}
31+
32+
mock_sentinel.return_value.master_for.assert_called_once()
33+
master_for_args = mock_sentinel.return_value.master_for.call_args
34+
assert master_for_args[0][0] == "mymaster"
35+
assert master_for_args[1]["db"] == "0"
36+
37+
assert client == mock_master
38+
39+
40+
@pytest.mark.parametrize("use_async", [False, True])
41+
def test_sentinel_url_connection_no_auth_no_db(use_async):
42+
sentinel_url = "redis+sentinel://host1:26379,host2:26380/mymaster"
43+
44+
with patch("redisvl.redis.connection.Sentinel") as mock_sentinel:
45+
mock_master = MagicMock()
46+
mock_sentinel.return_value.master_for.return_value = mock_master
47+
48+
if use_async:
49+
client = RedisConnectionFactory.get_async_redis_connection(sentinel_url)
50+
else:
51+
client = RedisConnectionFactory.get_redis_connection(sentinel_url)
52+
53+
mock_sentinel.assert_called_once()
54+
call_args = mock_sentinel.call_args
55+
assert call_args[0][0] == [("host1", 26379), ("host2", 26380)]
56+
assert (
57+
"sentinel_kwargs" not in call_args[1]
58+
or call_args[1]["sentinel_kwargs"] == {}
59+
)
60+
61+
mock_sentinel.return_value.master_for.assert_called_once()
62+
master_for_args = mock_sentinel.return_value.master_for.call_args
63+
assert master_for_args[0][0] == "mymaster"
64+
assert "db" not in master_for_args[1]
65+
66+
assert client == mock_master
67+
68+
69+
@pytest.mark.parametrize("use_async", [False, True])
70+
def test_sentinel_url_connection_error(use_async):
71+
sentinel_url = "redis+sentinel://host1:26379,host2:26380/mymaster"
72+
73+
with patch("redisvl.redis.connection.Sentinel") as mock_sentinel:
74+
mock_sentinel.return_value.master_for.side_effect = ConnectionError(
75+
"Test connection error"
76+
)
77+
78+
with pytest.raises(ConnectionError):
79+
if use_async:
80+
RedisConnectionFactory.get_async_redis_connection(sentinel_url)
81+
else:
82+
RedisConnectionFactory.get_redis_connection(sentinel_url)
83+
84+
mock_sentinel.assert_called_once()

0 commit comments

Comments
 (0)