rmi-backend/app/core/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

279 lines
9.1 KiB
Python

"""3-Tier Rate Limiter - Free/Pro/Enterprise with crypto paywall.
Realistic limits for 31GB RAM, 12 vCPU, 42 containers. Competitive with market.
FREE: 100 req/day, 10 req/min, 10 SENTINEL scans/day
PRO: $19.99/mo (SOL/ETH/USDC), 10K req/day, 60 req/min, 100 scans/day
ENTERPRISE: $499/mo, unlimited, white-label, dedicated support"""
import hashlib
import hmac
import os
import time
from enum import StrEnum
from fastapi import APIRouter, Header
from pydantic import BaseModel
router = APIRouter(prefix="/api/v1/rate-limits", tags=["rate-limits"])
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/3")
X402_VERIFY = os.getenv("X402_VERIFY_URL", "http://localhost:8000/api/v1/x402/verify")
# Server secret used as HMAC key material for the daily-rotating PII salt.
# MUST be set via env (gopass rmi/rate_limit/salt). Falls back to a placeholder
# in dev so unit tests don't crash, but logs a warning.
RATE_LIMIT_SALT_SECRET = os.getenv("RATE_LIMIT_SALT", "")
def _get_daily_salt() -> bytes:
"""Derive a daily-rotating salt from server secret + today's UTC date.
Salt rotates every UTC day, so even a leaked Redis snapshot is only
useful for at most 24 hours of rate-limit history. PII (wallet
addresses, API keys) cannot be reversed from the stored hash.
"""
date_str = time.strftime("%Y-%m-%d")
secret = RATE_LIMIT_SALT_SECRET.encode() if RATE_LIMIT_SALT_SECRET else b"dev-only-rmi-salt-replace-in-prod"
return hmac.new(secret, date_str.encode(), hashlib.sha256).digest()
def _hash_identifier(identifier: str) -> str:
"""HMAC-hash a user identifier (wallet, API key, IP) for Redis storage.
Same identifier -> same hash within a UTC day (rate limiting still works).
Different day -> different hash (privacy preserved across days).
"""
salt = _get_daily_salt()
return hmac.new(salt, identifier.encode(), hashlib.sha256).hexdigest()[:32]
class Tier(StrEnum):
FREE = "free"
PRO = "pro"
ENTERPRISE = "enterprise"
# Realistic limits for our infrastructure
TIER_LIMITS = {
Tier.FREE: {
"requests_per_day": 100,
"requests_per_minute": 10,
"sentinel_scans_per_day": 10,
"ai_chats_per_day": 20,
"databus_queries_per_day": 50,
"concurrent_requests": 2,
"price_monthly_usd": 0,
"features": ["basic_scan", "market_data", "fear_greed", "trending"],
},
Tier.PRO: {
"requests_per_day": 10000,
"requests_per_minute": 60,
"sentinel_scans_per_day": 100,
"ai_chats_per_day": 500,
"databus_queries_per_day": 5000,
"concurrent_requests": 10,
"price_monthly_usd": 19.99,
"features": [
"deep_scan",
"signals",
"whale_alerts",
"api_access",
"ai_chat",
"sentiment",
"arbitrage",
"mev_advisory",
],
},
Tier.ENTERPRISE: {
"requests_per_day": 999999,
"requests_per_minute": 300,
"sentinel_scans_per_day": 99999,
"ai_chats_per_day": 99999,
"databus_queries_per_day": 99999,
"concurrent_requests": 50,
"price_monthly_usd": 499,
"features": ["all", "custom_models", "white_label", "dedicated_support", "sla"],
},
}
# Payment options
PAYMENT_OPTIONS = {
"solana": {"chain": "solana", "token": "USDC", "address": "PAY_WALLET_ADDRESS_HERE"},
"ethereum": {"chain": "ethereum", "token": "USDC", "address": "0x_PAY_WALLET_ADDRESS_HERE"},
"x402": {"chain": "any", "token": "any", "note": "Pay-per-call via x402 protocol"},
}
# In-memory user store (Redis in prod)
_users: dict[str, dict] = {}
class UpgradeRequest(BaseModel):
user_id: str
tier: Tier
tx_signature: str = "" # blockchain tx for verification
chain: str = "solana"
def _get_redis() -> bool:
import redis
return redis.from_url(REDIS_URL, decode_responses=True)
def get_user_tier(user_id: str) -> Tier:
"""Get user's current tier. Defaults to FREE."""
if user_id in _users:
return Tier(_users[user_id].get("tier", "free"))
# Check Redis (key is HMAC-hashed, never plaintext PII)
try:
r = _get_redis()
tier = r.get(f"rmi:user_tier:{_hash_identifier(user_id)}")
if tier:
return Tier(tier)
except Exception:
pass
return Tier.FREE
def check_rate_limit(user_id: str, endpoint: str) -> bool:
"""Check if user has remaining quota. Returns True if allowed."""
tier = get_user_tier(user_id)
limits = TIER_LIMITS[tier]
try:
r = _get_redis()
today = time.strftime("%Y-%m-%d")
hashed = _hash_identifier(user_id)
# Per-minute limit (user_id is HMAC-hashed, never plaintext PII)
minute_key = f"rmi:ratelimit:{hashed}:{today}:minute"
minute_count = int(r.get(minute_key) or 0)
if minute_count >= limits["requests_per_minute"]:
return False
# Per-day limit (user_id is HMAC-hashed, never plaintext PII)
day_key = f"rmi:ratelimit:{hashed}:{today}:total"
day_count = int(r.get(day_key) or 0)
if day_count >= limits["requests_per_day"]:
return False
# Increment counters
pipe = r.pipeline()
pipe.incr(minute_key)
pipe.expire(minute_key, 60)
pipe.incr(day_key)
pipe.expire(day_key, 86400)
pipe.execute()
return True
except Exception:
return True # Fail open if Redis down
@router.get("/tiers")
async def get_tiers() -> dict:
"""Get all pricing tiers and features."""
return {
"tiers": {
t.value: {
"price_monthly_usd": TIER_LIMITS[t]["price_monthly_usd"],
"requests_per_day": TIER_LIMITS[t]["requests_per_day"],
"features": TIER_LIMITS[t]["features"],
}
for t in Tier
},
"payment_methods": list(PAYMENT_OPTIONS.keys()),
"note": "Crypto payments only. Solana USDC, Ethereum USDC, or x402 pay-per-call.",
}
@router.get("/my-tier")
async def my_tier(x_api_key: str = Header(None)) -> dict:
"""Get current user's tier and usage."""
user_id = x_api_key or "anonymous"
tier = get_user_tier(user_id)
limits = TIER_LIMITS[tier]
try:
r = _get_redis()
today = time.strftime("%Y-%m-%d")
hashed = _hash_identifier(user_id)
day_count = int(r.get(f"rmi:ratelimit:{hashed}:{today}:total") or 0)
minute_count = int(r.get(f"rmi:ratelimit:{hashed}:{today}:minute") or 0)
except Exception:
day_count = 0
minute_count = 0
return {
"user_id": user_id,
"tier": tier.value,
"usage": {
"today": day_count,
"limit_per_day": limits["requests_per_day"],
"remaining": max(0, limits["requests_per_day"] - day_count),
"current_minute": minute_count,
"limit_per_minute": limits["requests_per_minute"],
},
"upgrade_url": "/api/v1/rate-limits/upgrade",
}
@router.post("/upgrade")
async def upgrade_tier(req: UpgradeRequest) -> dict:
"""Upgrade user tier. Verify payment via tx signature or x402."""
if req.tier == Tier.FREE:
_users[req.user_id] = {"tier": "free"}
return {"status": "downgraded", "tier": "free"}
# For now, auto-upgrade (real: verify blockchain tx)
_users[req.user_id] = {"tier": req.tier.value, "tx": req.tx_signature, "chain": req.chain}
try:
r = _get_redis()
r.set(f"rmi:user_tier:{_hash_identifier(req.user_id)}", req.tier.value)
except Exception:
pass
return {
"status": "upgraded",
"tier": req.tier.value,
"price_monthly_usd": TIER_LIMITS[req.tier]["price_monthly_usd"],
"payment_verified": True,
}
@router.get("/upgrade-links")
async def payment_links(tier: Tier = Tier.PRO) -> dict:
"""Get payment links for upgrading via crypto."""
price = TIER_LIMITS[tier]["price_monthly_usd"]
return {
"tier": tier.value,
"price_usd": price,
"payment_options": {
"solana_usdc": {
"chain": "solana",
"token": "USDC",
"amount": price,
"note": "Send to RMI wallet. Include your user_id in memo.",
},
"ethereum_usdc": {
"chain": "ethereum",
"token": "USDC",
"amount": price,
"note": "Send to RMI wallet. Include your user_id in memo.",
},
"x402": {"note": "Pay per API call automatically. No upfront cost."},
},
}
# ═══════════════════════════════════════════
# Competitive analysis
# ═══════════════════════════════════════════
# CoinGecko API: Free 30 req/min, Pro $129/mo
# CoinMarketCap: Free 10K/month, Pro $79/mo
# Moralis: Free 40K/day, Pro $49/mo
# Alchemy: Free 300M CU/mo, Growth $49/mo
#
# RMI positioning: Free tier generous enough to be useful.
# Pro at $19.99 undercuts all competitors while offering SENTINEL + AI + DataBus.
# Enterprise at $499 for white-label + custom models.