# Super Search — Rate Limiting & Caching Reference Full implementation of `ratelimit.py` used in the Super Search MCP server (`/root/docker/super-search/ratelimit.py`). ## Architecture ``` ┌─ Cache HIT? ──→ return cached result │ Caller ──→ rate_limited_search(provider) │ ├─ Cache MISS ──→ acquire rate-limit token (TokenBucket) │ │ │ ┌──────┘ │ ├─ 200 OK ──→ cache.set(TTL) ──→ return │ ├─ 429/5xx ──→ _retry_with_backoff (max 3) │ └─ permanent failure ──→ raise ``` ## TokenBucket Async-aware token bucket with `acquire()` that blocks until a token is available: ```python class TokenBucket: def __init__(self, tokens: float, interval: float) -> None: self._capacity = float(tokens) self._tokens = float(tokens) self._interval = float(interval) self._refill_rate = self._capacity / self._interval self._last_refill = time.monotonic() self._lock = asyncio.Lock() def _refill(self) -> None: now = time.monotonic() elapsed = now - self._last_refill self._tokens = min(self._capacity, self._tokens + elapsed * self._refill_rate) self._last_refill = now async def acquire(self) -> None: async with self._lock: self._refill() if self._tokens >= 1.0: self._tokens -= 1.0 return wait = (1.0 - self._tokens) / self._refill_rate await asyncio.sleep(wait) async with self._lock: self._refill() self._tokens -= 1.0 ``` ## Provider Rate Limits Configured per provider based on API tier and pricing: | Provider | Tokens | Interval | Effective Rate | Rationale | |---------------|--------|----------|---------------|-------------------------------| | searxng | 30 | 1.0s | 30/sec | Local instance, virtually free | | exa | 10 | 1.0s | 10/sec | Paid tier, moderate allowance | | firecrawl | 5 | 60.0s | ~0.08/sec | 1k/month free tier, conserve | | opencorporates| 1 | 1.0s | ~1/sec | Free tier, no API key | | courtlistener | 10 | 6.0s | ~1.67/sec | Free tier, ~100/min max | ## TTLCache Dict-based with async lock and per-key expiration: ```python class TTLCache: def __init__(self) -> None: self._cache: dict[str, tuple[Any, float]] = {} # key → (value, expires_at) self._lock = asyncio.Lock() async def get(self, key: str) -> Optional[Any]: async with self._lock: entry = self._cache.get(key) if entry is None: return None value, expires_at = entry if time.monotonic() >= expires_at: del self._cache[key] return None return value async def set(self, key: str, value: Any, ttl: int) -> None: async with self._lock: self._cache[key] = (value, time.monotonic() + ttl) ``` ## Cache TTLs | Provider | TTL | Rationale | |---------------|--------|--------------------------------------------------| | searxng | 120s | Web results change frequently | | exa | 300s | Neural search — moderate freshness | | opencorporates| 3600s | Company records don't change often | | courtlistener | 3600s | Court opinions are infrequent additions | Cache key format: `{provider}:{query.strip().lower()}:{limit}` ## Retry with Backoff Handles transient failures (429, 503, 502, 504) with exponential backoff: ```python RETRYABLE_STATUSES: set[int] = {429, 503, 502, 504} async def _retry_with_backoff( coro_factory: Callable[[], Any], provider: str, max_retries: int = 3, base_delay: float = 1.0, ) -> Any: for attempt in range(max_retries + 1): try: result = await coro_factory() return result except httpx.HTTPStatusError as e: status = e.response.status_code if status not in RETRYABLE_STATUSES or attempt == max_retries: raise retry_after = e.response.headers.get("Retry-After", "") if retry_after and retry_after.isdigit(): delay = float(retry_after) else: delay = base_delay * (2 ** attempt) logger.warning( "Provider %s returned %d (attempt %d/%d). Retrying in %.1fs…", provider, status, attempt + 1, max_retries, delay, ) await asyncio.sleep(delay) ``` Delays: 1s → 2s → 4s → 8s. Respects `Retry-After` header when present. ## rate_limited_search Decorator Composite decorator: cache-check → rate-limit → retry → cache-store: ```python def rate_limited_search(provider: str): def decorator(func): @wraps(func) async def wrapper(query: str, limit: int = 5, **kwargs): # 1. Check cache key = cache_key(provider, query, limit) cached = await _cache.get(key) if cached is not None: return cached # 2. Acquire rate-limit token bucket = get_bucket(provider) await bucket.acquire() # 3. Execute with retry ttl = CACHE_TTL.get(provider, 300) async def _call(): return await func(query, limit=limit, **kwargs) result = await _retry_with_backoff(_call, provider) # 4. Cache result (only on success) if result: await _cache.set(key, result, ttl) return result return wrapper return decorator ``` ## Usage in server.py ```python from ratelimit import rate_limited_search @rate_limited_search("opencorporates") async def _opencorporates_search(query: str, limit: int = 5) -> list[dict]: """Search company records via OpenCorporates API.""" # ... implementation — rate limiting + caching handled by decorator @rate_limited_search("courtlistener") async def _courtlistener_search(query: str, limit: int = 5) -> list[dict]: """Search US court opinions via CourtListener API.""" # ... implementation ``` ## Dependencies - `asyncio` (stdlib) - `time` (stdlib) - `logging` (stdlib) - `functools.wraps` (stdlib) - `httpx` (for HTTPStatusError type in retry handler only) No external dependencies required for the rate-limiting module itself.