"""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 product_staging 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]) -> 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.""" logger.info("Redis host: %s port: %s", redis_host, redis_port) 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 ping(self) -> bool: """ Ping the Redis server. For more information see https://redis.io/commands/ping """ response = await self.client.ping() return bool(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) -> Optional[Dict[str, Any]]: """ 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], 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