- 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>
280 lines
10 KiB
Python
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
|