""" RMI Cache Manager - Unified caching layer with Redis + in-memory fallback. Proprietary system that auto-tracks credits, adapts TTLs, and monitors hit rates. Architecture: Redis (primary) → in-memory dict (fallback) → external API All external API calls go through cache_get_or_fetch(). Credit tracking prevents rate limit exhaustion on free tiers. Usage: from app.cache_manager import cache # Simple cached fetch data = await cache.get_or_fetch( key="coingecko:trending", ttl=120, fetcher=lambda: httpx_get("https://api.coingecko.com/api/v3/search/trending"), source="coingecko" ) # Stats monitoring stats = cache.get_stats() # {"coingecko": {"hits": 1423, "misses": 47, ...}} """ import asyncio import contextlib import json import os import time from collections.abc import Callable from typing import Any # ═══════════════════════════════════════════════════════════════ # PROVIDER CONFIG - TTLs, rate limits, credit tracking # ═══════════════════════════════════════════════════════════════ PROVIDER_CONFIG = { "coingecko": { "ttl": 120, # seconds (free tier: 30 calls/min) "rate_limit": 30, # calls per minute "rate_window": 60, # seconds "description": "CoinGecko public API", }, "coingecko_pro": { "ttl": 60, "rate_limit": 500, "rate_window": 60, "description": "CoinGecko Pro (our API key)", }, "dexscreener": { "ttl": 30, "rate_limit": 300, "rate_window": 60, "description": "DexScreener public API", }, "jupiter": { "ttl": 30, "rate_limit": 600, "rate_window": 60, "description": "Jupiter DEX aggregator", }, "birdeye": { "ttl": 60, "rate_limit": 60, "rate_window": 60, "description": "Birdeye token data", }, "dexscreener_security": { "ttl": 300, "rate_limit": 100, "rate_window": 60, "description": "DexScreener security endpoint", }, "defillama": { "ttl": 300, "rate_limit": 100, "rate_window": 60, "description": "DeFiLlama TVL data", }, "moralis": { "ttl": 300, "rate_limit": 100, "rate_window": 60, "description": "Moralis Web3 API (our key)", }, "helius": { "ttl": 60, "rate_limit": 200, "rate_window": 60, "description": "Helius Solana RPC (our key)", }, "gmgn": { "ttl": 120, "rate_limit": 60, "rate_window": 60, "description": "GMGN trending/wallet data", }, "rugcheck": { "ttl": 300, "rate_limit": 100, "rate_window": 60, "description": "RugCheck security API", }, "trmlabs": { "ttl": 600, "rate_limit": 50, "rate_window": 60, "description": "TRM Labs sanctions screening", }, "polymarket": { "ttl": 120, "rate_limit": 200, "rate_window": 60, "description": "Polymarket prediction data", }, "coincap": { "ttl": 60, "rate_limit": 200, "rate_window": 60, "description": "CoinCap price data", }, "nansen": { "ttl": 300, "rate_limit": 50, "rate_window": 60, "description": "Nansen on-chain analytics (paid key)", }, "dune": { "ttl": 600, "rate_limit": 30, "rate_window": 60, "description": "Dune Analytics (paid key)", }, "solscan": { "ttl": 120, "rate_limit": 100, "rate_window": 60, "description": "Solscan Solana explorer API", }, "quicknode": { "ttl": 60, "rate_limit": 300, "rate_window": 60, "description": "QuickNode RPC (paid key)", }, "alchemy": { "ttl": 60, "rate_limit": 300, "rate_window": 60, "description": "Alchemy multi-chain RPC (paid key)", }, "solana_rpc": { "ttl": 30, "rate_limit": 500, "rate_window": 60, "description": "Solana RPC (helius/quicknode combo)", }, "coinmarketcap": { "ttl": 120, "rate_limit": 100, "rate_window": 60, "description": "CoinMarketCap API", }, "etherscan": { "ttl": 300, "rate_limit": 5, "rate_window": 1, "description": "Etherscan API (free tier: 5 calls/sec)", }, "bitquery": { "ttl": 300, "rate_limit": 100, "rate_window": 60, "description": "Bitquery GraphQL blockchain data", }, } # Providers with API keys (free tiers with limits) KEYED_PROVIDERS = { "coingecko_pro", "moralis", "helius", "trmlabs", "nansen", "dune", "solscan", "quicknode", "alchemy", "coinmarketcap", "etherscan", "bitquery", "birdeye", "gmgn", } # Truly free (no keys at all) FREE_PROVIDERS = { "coingecko", "dexscreener", "jupiter", "defillama", "polymarket", "coincap", "rugcheck", } class RMICache: """Centralized cache with Redis primary + dict fallback + credit tracking.""" def __init__(self): self._redis = None self._redis_available = False self._memory: dict[str, tuple[Any, float]] = {} # Credit tracking: {source: {"hits": N, "misses": N, "calls": [], "bytes": N}} self._stats: dict[str, dict[str, Any]] = {} self._lock = asyncio.Lock() self._init_redis() def _init_redis(self): try: import redis.asyncio as redis self._redis = redis.Redis( host=os.getenv("REDIS_HOST", "rmi-redis"), port=int(os.getenv("REDIS_PORT", "6379")), password=os.getenv("REDIS_PASSWORD") or None, db=int(os.getenv("REDIS_DB", "0")), socket_connect_timeout=2, socket_timeout=2, decode_responses=True, ) self._redis_available = True except Exception: self._redis_available = False async def _redis_ping(self) -> bool: if not self._redis or not self._redis_available: return False try: await self._redis.ping() return True except Exception: self._redis_available = False return False async def _redis_get(self, key: str) -> Any | None: try: if await self._redis_ping(): raw = await self._redis.get(f"rmi:cache:{key}") if raw: return json.loads(raw) except Exception: pass return None async def _redis_set(self, key: str, value: Any, ttl: int): try: if await self._redis_ping(): await self._redis.setex(f"rmi:cache:{key}", ttl, json.dumps(value, default=str)) except Exception: pass def _mem_get(self, key: str) -> Any | None: entry = self._memory.get(key) if entry and time.time() < entry[1]: return entry[0] return None def _mem_set(self, key: str, value: Any, ttl: int): self._memory[key] = (value, time.time() + ttl) def _get_stats(self, source: str) -> dict[str, Any]: if source not in self._stats: self._stats[source] = { "hits": 0, "misses": 0, "calls": [], "bytes_saved": 0, "rate_limited": 0, "last_call": 0, "provider": PROVIDER_CONFIG.get(source, {}), } return self._stats[source] def _check_rate_limit(self, source: str) -> bool: """Check if we're about to exceed rate limit. Returns True if OK to call.""" cfg = PROVIDER_CONFIG.get(source, {}) limit = cfg.get("rate_limit", 100) window = cfg.get("rate_window", 60) st = self._get_stats(source) now = time.time() # Prune old calls st["calls"] = [t for t in st["calls"] if now - t < window] if len(st["calls"]) >= limit: st["rate_limited"] = st.get("rate_limited", 0) + 1 return False return True async def get_or_fetch( self, key: str, ttl: int | None = None, fetcher: Callable | None = None, source: str = "unknown", force: bool = False, ) -> Any | None: """ Get from cache or fetch from source. Tracks credits automatically. Args: key: Cache key (e.g., "coingecko:trending") ttl: Override TTL (default: from PROVIDER_CONFIG) fetcher: Async function that returns data (called on cache miss) source: Provider name (must be in PROVIDER_CONFIG) force: Skip cache, force fresh fetch Returns: Cached or freshly fetched data, or None on failure. """ if ttl is None: ttl = PROVIDER_CONFIG.get(source, {}).get("ttl", 60) st = self._get_stats(source) # Check cache (unless forced) if not force: # Try Redis first cached = await self._redis_get(key) if cached is not None: st["hits"] = st.get("hits", 0) + 1 return cached # Fallback to memory cached = self._mem_get(key) if cached is not None: st["hits"] = st.get("hits", 0) + 1 return cached # Cache miss - check rate limit before calling external API if not self._check_rate_limit(source): # Rate limited - return stale data if available, else None stale = self._mem_get(key) # memory may have expired but better than nothing return stale # Fetch fresh data if fetcher is None: return None st["misses"] = st.get("misses", 0) + 1 st["last_call"] = time.time() st["calls"].append(time.time()) try: data = await fetcher() if data is not None: # Store in both caches await self._redis_set(key, data, ttl) self._mem_set(key, data, ttl) # Track bytes saved with contextlib.suppress(Exception): st["bytes_saved"] = st.get("bytes_saved", 0) + len(json.dumps(data, default=str)) return data except Exception: return None def get_stats(self) -> dict[str, Any]: """Get comprehensive cache statistics for monitoring.""" sources = {} total_hits = 0 total_misses = 0 total_rate_limited = 0 total_bytes = 0 for source, st in self._stats.items(): hits = st.get("hits", 0) misses = st.get("misses", 0) total = hits + misses sources[source] = { "hits": hits, "misses": misses, "hit_rate": round(hits / total * 100, 1) if total > 0 else 0, "rate_limited": st.get("rate_limited", 0), "bytes_saved": st.get("bytes_saved", 0), "provider": st.get("provider", {}), "calls_this_window": len(st.get("calls", [])), } total_hits += hits total_misses += misses total_rate_limited += st.get("rate_limited", 0) total_bytes += st.get("bytes_saved", 0) total = total_hits + total_misses return { "summary": { "total_hits": total_hits, "total_misses": total_misses, "hit_rate": round(total_hits / total * 100, 1) if total > 0 else 0, "rate_limited": total_rate_limited, "bytes_saved": total_bytes, "redis_available": self._redis_available, "memory_keys": len(self._memory), }, "sources": sources, } def warm_cache(self, key: str, data: Any, ttl: int | None = None, source: str = "manual"): """Pre-warm cache with data (e.g., from cron jobs).""" if ttl is None: ttl = PROVIDER_CONFIG.get(source, {}).get("ttl", 60) asyncio.ensure_future(self._redis_set(key, data, ttl)) self._mem_set(key, data, ttl) async def invalidate(self, key: str): """Force-invalidate a cache key.""" try: if self._redis and self._redis_available: await self._redis.delete(f"rmi:cache:{key}") except Exception: pass self._memory.pop(key, None) # Singleton instance cache = RMICache()