| """ |
| Aggressive Caching Shield β Token Bucket Rate Limiter |
| Redis-backed rate limiting to stay under free tier RPC limits. |
| |
| Free tier limits (per second): |
| Helius: 25 RPS (free), 50 RPS (starter) |
| QuickNode: 25 RPS (free) |
| Alchemy: 25 RPS (free), 330 RPS (growth) |
| dRPC: 15 RPS (free, shared) |
| PublicNode: 10 RPS (implicit fair use) |
| |
| Strategy: |
| - Default bucket: 15 tokens/sec, burst of 25 (safe for all free tiers) |
| - Per-method buckets for expensive calls (getProgramAccounts: 5/s) |
| - Blocks requests when bucket is empty instead of queueing |
| - Rate limit headers returned to callers for backpressure |
| - Falls back to in-memory if Redis unavailable |
| """ |
|
|
| import asyncio |
| import logging |
| import os |
| import time |
| from dataclasses import dataclass, field |
|
|
| import redis.asyncio as aioredis |
|
|
| logger = logging.getLogger("rpc_rate_limiter") |
|
|
| |
|
|
|
|
| @dataclass |
| class ProviderLimit: |
| name: str |
| tokens_per_sec: float |
| burst_size: int |
| |
| method_limits: dict[str, tuple[float, int]] = field(default_factory=dict) |
|
|
|
|
| PROVIDER_LIMITS: dict[str, ProviderLimit] = { |
| "helius": ProviderLimit( |
| "helius", |
| 20.0, |
| 25, |
| { |
| "getProgramAccounts": (5.0, 5), |
| "getSignaturesForAddress": (10.0, 15), |
| }, |
| ), |
| "quicknode": ProviderLimit("quicknode", 20.0, 25), |
| "alchemy": ProviderLimit( |
| "alchemy", |
| 20.0, |
| 25, |
| { |
| "getProgramAccounts": (5.0, 5), |
| }, |
| ), |
| "drpc": ProviderLimit("drpc", 12.0, 15), |
| "publicnode": ProviderLimit("publicnode", 8.0, 10), |
| "anvil": ProviderLimit("anvil", 5.0, 8), |
| "1rpc": ProviderLimit("1rpc", 15.0, 20), |
| "llama_rpc": ProviderLimit("llama_rpc", 15.0, 20), |
| "blastapi": ProviderLimit("blastapi", 15.0, 20), |
| "_default": ProviderLimit("_default", 10.0, 15), |
| } |
|
|
| |
| BUCKET_PREFIX = "rmi:ratelimit:" |
| BURST_PREFIX = "rmi:ratelimit_burst:" |
|
|
| |
| MEM_BUCKET_CLEANUP_INTERVAL = 60 |
|
|
|
|
| class RpcRateLimiter: |
| """Token bucket rate limiter using Redis (with in-memory fallback). |
| |
| Usage: |
| limiter = RpcRateLimiter() |
| allowed, wait_time = await limiter.acquire("helius", "getBalance") |
| if not allowed: |
| raise RateLimitExceeded(f"Try again in {wait_time:.1f}s") |
| """ |
|
|
| def __init__(self, redis_url=None, redis_password=None): |
| self._redis = None |
| self._redis_url = redis_url |
| self._redis_password = redis_password |
| self._redis_failed = False |
| self._init_lock = asyncio.Lock() |
| |
| self._mem_buckets: dict[str, tuple[float, float, int]] = {} |
| self._mem_lock = asyncio.Lock() |
|
|
| async def _get_redis(self): |
| if self._redis is not None: |
| return self._redis |
| if self._redis_failed: |
| return None |
| async with self._init_lock: |
| if self._redis is not None: |
| return self._redis |
| if self._redis_failed: |
| return None |
| try: |
| host = self._redis_url or os.getenv("REDIS_HOST", "rmi-redis") |
| port = int(os.getenv("REDIS_PORT", "6379")) |
| password = self._redis_password or os.getenv("REDIS_PASSWORD", "") |
| url = f"redis://:{password}@{host}:{port}" if password else f"redis://{host}:{port}" |
| self._redis = aioredis.from_url(url, socket_connect_timeout=2, decode_responses=False) |
| await self._redis.ping() |
| logger.info("RpcRateLimiter: Redis connected OK") |
| return self._redis |
| except Exception as e: |
| logger.warning(f"RpcRateLimiter: Redis unavailable ({e}), using in-memory") |
| self._redis_failed = True |
| return None |
|
|
| def _get_limit(self, provider: str, method: str) -> tuple[float, int]: |
| """Get the effective rate limit for a provider/method combo.""" |
| pl = PROVIDER_LIMITS.get(provider, PROVIDER_LIMITS["_default"]) |
| if method in pl.method_limits: |
| return pl.method_limits[method] |
| return (pl.tokens_per_sec, pl.burst_size) |
|
|
| async def acquire(self, provider: str, method: str = "", tokens: int = 1) -> tuple[bool, float]: |
| """Try to acquire tokens. Returns (allowed, wait_seconds).""" |
| rate, burst = self._get_limit(provider, method) |
| redis = await self._get_redis() |
|
|
| if redis: |
| return await self._acquire_redis(redis, provider, method, rate, burst, tokens) |
| else: |
| return await self._acquire_memory(provider, method, rate, burst, tokens) |
|
|
| async def _acquire_redis(self, redis, provider, method, rate, burst, tokens): |
| """Redis-based token bucket using Lua script for atomicity.""" |
| bucket_key = f"{BUCKET_PREFIX}{provider}" |
| burst_key = f"{BURST_PREFIX}{provider}" |
|
|
| |
| lua = """ |
| local bucket_key = KEYS[1] |
| local burst_key = KEYS[2] |
| local rate = tonumber(ARGV[1]) |
| local burst = tonumber(ARGV[2]) |
| local tokens = tonumber(ARGV[3]) |
| local now = tonumber(ARGV[4]) |
| |
| -- Read current state |
| local tokens_val = redis.call('GET', bucket_key) |
| local last_refill = redis.call('GET', burst_key) |
| local current_tokens = burst |
| |
| if tokens_val and last_refill then |
| current_tokens = tonumber(tokens_val) |
| local elapsed = now - tonumber(last_refill) |
| local refill = elapsed * rate |
| current_tokens = math.min(burst, current_tokens + refill) |
| end |
| |
| -- Can we consume? |
| if current_tokens >= tokens then |
| current_tokens = current_tokens - tokens |
| redis.call('SETEX', bucket_key, 60, current_tokens) |
| redis.call('SET', burst_key, now) |
| return {1, 0} |
| else |
| -- How long until we have enough? |
| local needed = tokens - current_tokens |
| local wait = needed / rate |
| return {0, math.ceil(wait * 100) / 100} |
| end |
| """ |
|
|
| try: |
| result = await redis.eval( |
| lua, 2, bucket_key, burst_key, str(rate), str(burst), str(tokens), str(time.time()) |
| ) |
| allowed = bool(result[0]) |
| wait = float(result[1]) |
| return (allowed, wait) |
| except Exception as e: |
| logger.debug(f"Redis rate limiter error: {e}, falling back to memory") |
| return await self._acquire_memory(provider, method, rate, burst, tokens) |
|
|
| async def _acquire_memory(self, provider, method, rate, burst, tokens): |
| """In-memory fallback token bucket.""" |
| async with self._mem_lock: |
| now = time.monotonic() |
| bucket = self._mem_buckets.get(provider) |
|
|
| if bucket: |
| current, last_refill, _used = bucket |
| elapsed = now - last_refill |
| current = min(burst, current + elapsed * rate) |
| else: |
| current = burst |
|
|
| if current >= tokens: |
| current -= tokens |
| self._mem_buckets[provider] = (current, now, 0) |
| return (True, 0.0) |
| else: |
| needed = tokens - current |
| wait = needed / rate |
| return (False, wait) |
|
|
| async def stats(self, provider: str | None = None) -> dict: |
| """Return rate limiter stats.""" |
| result = {} |
| providers = [provider] if provider else list(PROVIDER_LIMITS.keys()) |
|
|
| redis = await self._get_redis() |
| for p in providers: |
| pl = self._get_limit(p, "") |
| if redis: |
| try: |
| tokens_b = await redis.get(f"{BUCKET_PREFIX}{p}") |
| last_b = await redis.get(f"{BURST_PREFIX}{p}") |
| tokens = float(tokens_b) if tokens_b else pl[1] |
| last = float(last_b or 0) |
| except Exception: |
| tokens = pl[1] |
| last = 0 |
| else: |
| async with self._mem_lock: |
| bucket = self._mem_buckets.get(p) |
| if bucket: |
| tokens, last, _used = bucket |
| else: |
| tokens = pl[1] |
| last = 0 |
|
|
| result[p] = { |
| "available_tokens": round(tokens, 1), |
| "burst_limit": pl[1], |
| "rate": pl[0], |
| "seconds_since_refill": round(time.time() - last, 1) if last else 0, |
| } |
| return result |
|
|
| async def get_bucket_state(self, provider: str) -> tuple[float, float, int]: |
| """Get current state of a provider's token bucket. |
| Returns (tokens, last_refill_ts, burst_used). |
| """ |
| pl = self._get_limit(provider, "") |
| redis = await self._get_redis() |
| if redis: |
| try: |
| tokens = await redis.get(f"{BUCKET_PREFIX}{provider}") |
| last = await redis.get(f"{BURST_PREFIX}{provider}") |
| if tokens is not None and last is not None: |
| return (float(tokens), float(last), 0) |
| except Exception: |
| pass |
| async with self._mem_lock: |
| bucket = self._mem_buckets.get(provider) |
| if bucket: |
| return bucket |
| return (float(pl[1]), 0.0, 0) |
|
|
| def get_all_limits(self) -> dict: |
| """Return all configured provider limits (read-only).""" |
| return PROVIDER_LIMITS.copy() |
|
|
|
|
| |
|
|
| _limiter: RpcRateLimiter | None = None |
|
|
|
|
| def get_rate_limiter() -> RpcRateLimiter: |
| global _limiter |
| if _limiter is None: |
| _limiter = RpcRateLimiter() |
| return _limiter |
|
|