vutran1710/PyrateLimiter

Redis Sentinel / pool support

Closed this issue · 8 comments

Hi there, In 2.6.0 we are using redis connection pooling to handle ratelimiting. I see in 3.x this has been removed but the redis connection could easily go away in which case I think you cannot correctly handle retries?

Either way, in 2.6.0 there is also no obvious way to support Redis Sentinel for cluster management.

Perhaps a better redis interface would be for the caller to supply an (async)contextmanager reference which will return a redis connection to the redis rate limiter interface, and then it's down to the app to figure out retries/reconnects and whether they use connection pooling, clustering, sentinel etc?

Yeah, your point is totally correct! Since I'm about to have some free time in the next few days, I'll try looking into it.

To my knowledge, you can setup your redis bucket with either connection pool or redis-sentinel supported like this

Using redis sentinel

async def create_sentinel_redis_bucket(rates: List[Rate]):
    from redis.sentinel import Sentinel

    sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1)
    master = sentinel.master_for('mymaster', socket_timeout=0.1)
    bucket_key = f"test-bucket/{id_generator()}"
    assert master.ping()
    master.delete(bucket_key)
    bucket = RedisBucket.init(rates, master, bucket_key)
    assert bucket.count() == 0
    return bucket

Or with connection pool, either sync or async

async def create_redis_bucket(rates: List[Rate]):
    from redis import ConnectionPool
    from redis import Redis

    pool = ConnectionPool.from_url(getenv("REDIS", "redis://localhost:6379"))
    redis_db = Redis(connection_pool=pool)
    bucket_key = f"test-bucket/{id_generator()}"
    redis_db.delete(bucket_key)
    bucket = RedisBucket.init(rates, redis_db, bucket_key)
    assert bucket.count() == 0
    return bucket


async def create_async_redis_bucket(rates: List[Rate]):
    from redis.asyncio import ConnectionPool as AsyncConnectionPool
    from redis.asyncio import Redis as AsyncRedis

    pool = AsyncConnectionPool.from_url(getenv("REDIS", "redis://localhost:6379"))
    redis_db: AsyncRedis = AsyncRedis(connection_pool=pool)
    bucket_key = f"test-bucket/{id_generator()}"
    await redis_db.delete(bucket_key)
    bucket = await RedisBucket.init(rates, redis_db, bucket_key)
    assert await bucket.count() == 0
    return bucket

Yes, however I believe above in your sentinel pool example it returns a connection to the master from the current sentinel configuration, however if that master goes down the connections will break and not automatically switch over to the new master.

Hence needing to call master_for() each time we go into a redis section to pull a (potentially new) master connection out of the pool

So if apparently redis' Sentinel class does not retry on master failover for us automatically, can we can create a supporting class for it and pass it to the RedisBucket class?

I think this might work

class SentinelRedis:
    from redis.sentinel import Sentinel

    """A class that wraps around redis sentinel and automatically resolves the master node"""
    def __init__(self, sentinel: Sentinel):
        self.sentinel = sentinel
        self.available_methods = [
            "evalsha",
            "zremrangebyscore",
            "delete",
            "zcard",
            "zrange"
        ]

    def __getattr__(self, name):
        if name not in self.available_methods:
            raise AttributeError

        master = None
        while master is None:
            try:
                found_master = self.sentinel.discover_master("mymaster")
                master = found_master
            except Exception as err:
                logger.error("Error while discovering master: %s", err)

        return getattr(master, name)

And then...

async def create_sentinel_redis_bucket(rates: List[Rate]) -> AbstractBucket:
    from redis.sentinel import Sentinel
    
    redis_db = SentinelRedis(Sentinel([("localhost", 26379)], socket_timeout=0.1))
    bucket = RedisBucket.init(rates, redis_db, "test-bucket")
    return bucket

OK reading through the code in redis 5 it looks like I can do something like:

sentinel = Sentinel(...)
master_pool = SentinelConnectionPool("service", sentinel, is_master=True, ...)

and pass that pool in and hopefully it auto-reconnects to the master as required.

I got this working. Probably add the snippet to the documentation if you believe the setup is correct

from redis.sentinel import Sentinel
from redis.sentinel import SentinelConnectionPool
from redis import Redis

sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1)
pool = SentinelConnectionPool("mymaster", sentinel, ssl=False, check_connection=True, is_master=True, decode_responses=True)
redis_db = Redis(connection_pool=pool)
bucket_key = f"test-sentinel/{id_generator()}"
bucket = RedisBucket.init(rates, redis_db, bucket_key)

OK We're still using limiter 2.5.0 (not 2.6.0 as I said in my initial ticket) so I think it's slightly different with what is required to go in. If you are doing the above then just .master_for() will work because it effectively does your pool/redis_db steps. But for 2.5.0 I believe the connection pool is passed in rather than the Redis object itself.

Either way I tihnk this is resolved now thank you :)