"""Client wrapper for a redis connection.""" import json import logging from typing import Any, AnyStr, Dict, List, Optional, TypeVar import redis.asyncio as redis from fakeredis.aioredis import FakeRedis from redis.exceptions import RedisError from contributor import config logger = logging.getLogger(__name__) T = TypeVar("T") TypeAdapterT = TypeVar("TypeAdapterT") class JSONSerializer: """Plain old JSON serializer.""" def load(self, cached_data: str | bytes) -> Optional[Dict[str, Any]]: """Convert json-string to Dictionary.""" if not cached_data: return None result: Dict[str, Any] = json.loads(cached_data) return result def dump(self, data: Dict[str, Any] | List[Any]) -> Optional[str]: """Convert dictionary to json-string.""" if not data: return None return json.dumps(data) def _safe_load( key: AnyStr, cached_data: str, serializer: JSONSerializer, ) -> Optional[Dict[str, Any]]: """Attempt to load the item, handle exceptions, and log errors.""" result = None try: result = serializer.load(cached_data) except ValueError, TypeError: # Return 'None' for load errors. Callers should consider this a cache miss. # Both JSONDecodeError and pydantic.ValidationError are subtypes of ValueError. logger.error( "Load failed for key '%s' with serializer type: '%s'", key, type(serializer), exc_info=True, ) except Exception: logger.error( "Unexpected load error for key '%s' with serializer type: '%s'", key, type(serializer), exc_info=True, ) return result class RedisConnector: """Connect to Redis. When adding new command support to this RedisConnector class, wrap any calls to self.client.method_name with try...except RedisError... Redis is used as a cache, and in the event of any RedisError, treat it as a cache-miss. """ def __init__( self, redis_host: str = config.REDIS_HOST, redis_port: int = config.REDIS_PORT, use_redis_cache: bool = True, ): """Create a redis or fake redis connector.""" if use_redis_cache: _client: redis.client.Redis = redis.Redis( host=redis_host, port=redis_port, ssl=config.REDIS_SSL, socket_connect_timeout=config.REDIS_SOCKET_CONNECT_TIMEOUT, socket_timeout=config.REDIS_SOCKET_TIMEOUT, ) else: _client: FakeRedis = FakeRedis() self._client = _client self._serializer = JSONSerializer() @property def client(self) -> redis.Redis: """Returns the internal redis client.""" return self._client async def close(self) -> None: """Close the underlying redis client connection.""" try: await self.client.aclose() except RedisError: logger.error("[RedisError] close failed", exc_info=True) async def ping(self) -> bool: """ Ping the Redis server. For more information see https://redis.io/commands/ping """ response = self.client.ping() if isinstance(response, bool): return response awaited_response = await response return bool(awaited_response) async def mget(self, keys: List[str]) -> List[Optional[Dict[str, Any]]]: """ Fetch cached entries using Redis.MGET and deserialize to a schema type. Args: keys: List of redis keys to fetch Returns: A list of deserialized cached items. List entries can be 'None' if the deserialization fails. """ if not keys: return [] decoded_entries: List[Optional[Dict[str, Any]]] = [] try: cached_data_entries = await self.client.mget(keys=keys) except RedisError: logger.error( "[RedisError] mget failed, returning empty array", exc_info=True ) return decoded_entries if not cached_data_entries: return [] for key, cached_data in zip(keys, cached_data_entries): result = _safe_load( key=key, cached_data=cached_data, serializer=self._serializer ) decoded_entries.append(result) return decoded_entries async def get(self, key: str) -> dict[str, Any] | list[Any] | None: """ Fetch one cached entry and deserialize to a schema type. Args: key: Redis key to fetch Returns: A deserialized cached item """ result: List[Optional[Dict[str, Any]]] = await self.mget([key]) if not result: return None return result[0] async def set( self, key: str, item: dict[str, Any] | list[Any], ttl: Optional[int] = config.REDIS_CACHE_TTL, ) -> bool: """ Serialize the item and write a cached entry using Redis.SET. Args: key: Redis key to store the value item: Python object to store serializer: Object to serialize the Python object to a JSON-formatted strings ttl: Cache EXPIRE time in seconds. Returns: True if the SET command succeeds. Otherwise, False. """ if not item: return False json_data = self._serializer.dump(item) try: if json_data: result = await self.client.set(key, json_data, ex=ttl) except RedisError: logger.error("[RedisError] set failed, returning False", exc_info=True) return False return bool(result) async def mset( self, key_item_dict: Dict[str, Any], ttl: Optional[int] = config.REDIS_CACHE_TTL, ) -> Dict[str, bool]: """Write multiple cached entries using Redis.SET. Args: key_item_dict (Dict[str, Any]): _description_ serializer (PPCacheItemSerializer): _description_ Returns: Dict of cached keys (key) and the results of the SET commands (value). { "key1": True, "key2": False, } """ dict_results = {} try: for k, v in key_item_dict.items(): # We are calling set method from this class, which calls Redis.SET result = await self.set(k, v, ttl=ttl) dict_results[k] = result except RedisError: logger.error("[RedisError] set failed, returning False", exc_info=True) dict_results[k] = False return dict_results