rmi-backend/app/caching_shield/rate_limiter.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

280 lines
10 KiB
Python

"""
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")
# ── Provider Rate Limits ───────────────────────────────────────────────────
@dataclass
class ProviderLimit:
name: str
tokens_per_sec: float
burst_size: int
# Additional per-method constraints
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 for Redis keys
BUCKET_PREFIX = "rmi:ratelimit:"
BURST_PREFIX = "rmi:ratelimit_burst:"
# In-memory fallback
MEM_BUCKET_CLEANUP_INTERVAL = 60 # seconds
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()
# In-memory fallback: provider -> (tokens, last_refill_ts, burst_used)
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 script for atomic token bucket check + consume
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()
# ── Singleton ──────────────────────────────────────────────────────────────
_limiter: RpcRateLimiter | None = None
def get_rate_limiter() -> RpcRateLimiter:
global _limiter
if _limiter is None:
_limiter = RpcRateLimiter()
return _limiter