""" Redis cache module for high-traffic endpoints. Provides caching decorator and invalidation utilities. """ import redis import json import logging import hashlib import time from functools import wraps from config import REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_TTL_DEFAULT logger = logging.getLogger(__name__) _redis_client = None _redis_last_fail = 0 def get_redis(): """Get Redis client singleton with failure backoff.""" global _redis_client, _redis_last_fail if _redis_client is not None: return _redis_client # Prevent retrying too often if it's failing (60s backoff) now = time.time() if now - _redis_last_fail < 60: return None try: redis_config = { 'host': REDIS_HOST, 'port': REDIS_PORT, 'decode_responses': True, 'socket_connect_timeout': 1, # Faster timeout 'socket_timeout': 1 } if REDIS_PASSWORD: redis_config['password'] = REDIS_PASSWORD _redis_client = redis.Redis(**redis_config) _redis_client.ping() _redis_last_fail = 0 return _redis_client except Exception as e: logger.warning(f"Redis connection failed: {e}. Caching disabled for 60s.") _redis_client = None _redis_last_fail = now return None def cached(ttl_seconds=None, prefix="cache"): """ Decorator for caching function results in Redis. Falls back to calling function directly if Redis is unavailable. Args: ttl_seconds: Time to live in seconds (default from config) prefix: Key prefix for cache entries """ if ttl_seconds is None: ttl_seconds = REDIS_TTL_DEFAULT def decorator(func): @wraps(func) def wrapper(*args, **kwargs): r = get_redis() if r is None: # Redis unavailable, call function directly return func(*args, **kwargs) # Build cache key from function name and arguments # Use md5 for deterministic hash across processes key_data = f"{args}:{sorted(kwargs.items())}" # Add flask request args if available to prevent collision on filtered routes try: from flask import request if request: key_data += f":args:{sorted(request.args.items())}" except Exception: pass key_hash = hashlib.md5(key_data.encode('utf-8')).hexdigest() cache_key = f"cache:{prefix}:{func.__name__}:{key_hash}" try: # Try to get from cache cached_value = r.get(cache_key) if cached_value is not None: # If it's a JSON response, we might need to return it correctly try: data = json.loads(cached_value) # Detect if we should return as JSON from flask import jsonify return jsonify(data) except (json.JSONDecodeError, ImportError): return cached_value # Cache miss - call function and cache result result = func(*args, **kwargs) # Handle Flask Response objects cache_data = result try: from flask import Response if isinstance(result, Response): if result.is_json: cache_data = result.get_json() else: cache_data = result.get_data(as_text=True) except (ImportError, Exception): pass r.setex(cache_key, ttl_seconds, json.dumps(cache_data, default=str)) return result except (redis.RedisError, json.JSONDecodeError) as e: logger.warning(f"Cache error for {func.__name__}: {e}") return func(*args, **kwargs) return wrapper return decorator def invalidate_pattern(pattern): """ Invalidate all cache keys matching pattern. Args: pattern: Pattern to match (e.g., "home:*" or "stats:*") """ r = get_redis() if r is None: return try: cursor = 0 deleted = 0 while True: cursor, keys = r.scan(cursor, match=f"cache:{pattern}", count=100) if keys: r.delete(*keys) deleted += len(keys) if cursor == 0: break if deleted > 0: logger.info(f"Invalidated {deleted} cache keys matching '{pattern}'") except redis.RedisError as e: logger.warning(f"Cache invalidation failed: {e}") def cache_get(key): """Get value from cache by key.""" r = get_redis() if r is None: return None try: value = r.get(f"cache:{key}") return json.loads(value) if value else None except (redis.RedisError, json.JSONDecodeError): return None def cache_set(key, value, ttl_seconds=None): """Set value in cache with optional TTL.""" if ttl_seconds is None: ttl_seconds = REDIS_TTL_DEFAULT r = get_redis() if r is None: return False try: r.setex(f"cache:{key}", ttl_seconds, json.dumps(value, default=str)) return True except redis.RedisError: return False def cache_del(key): """Delete a key from cache.""" r = get_redis() if r is None: return False try: r.delete(f"cache:{key}") return True except redis.RedisError: return False