- 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>
184 lines
6.2 KiB
Python
184 lines
6.2 KiB
Python
"""
|
|
DataBus Provider Core - Infrastructure & Base Classes
|
|
======================================================
|
|
|
|
Circuit breakers, rate limiters, quota tracking, and core provider classes.
|
|
This module contains NO external API implementations.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import time
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger("databus.providers.core")
|
|
|
|
|
|
class ProviderTier(Enum):
|
|
"""Provider tiers: LOCAL > FREE_API > FREEMIUM > PAID"""
|
|
|
|
LOCAL = "local" # Our own data - instant, free, unlimited
|
|
FREE_API = "free_api" # Free external API - no key needed
|
|
FREEMIUM = "freemium" # Free tier with key - limited credits
|
|
PAID = "paid" # Paid API - precious credits
|
|
|
|
|
|
@dataclass
|
|
class Provider:
|
|
"""A single data source in a fallback chain."""
|
|
|
|
name: str
|
|
tier: ProviderTier
|
|
fetch_fn: Callable = field(repr=False)
|
|
weight: float = 1.0 # Higher = preferred within tier
|
|
rate_limit_rps: float = 1.0
|
|
monthly_quota: int = 0 # 0 = unlimited
|
|
requires_key: bool = False
|
|
key_env: str = ""
|
|
timeout: float = 15.0
|
|
is_local: bool = False # True if this provider uses our own data (no external API)
|
|
description: str = "" # Human-readable description
|
|
# Circuit breaker
|
|
failure_threshold: int = 5
|
|
recovery_timeout: float = 60.0
|
|
|
|
|
|
@dataclass
|
|
class ProviderChain:
|
|
"""A fallback chain for a specific data type."""
|
|
|
|
data_type: str
|
|
providers: list[Provider]
|
|
description: str = ""
|
|
|
|
async def fetch(self, vault: Any = None, cache: Any = None, **kwargs: Any) -> Any | None:
|
|
"""Try each provider in order until one succeeds.
|
|
|
|
Smart fallback: when paid provider quota is >80% used, skip to free/local
|
|
alternatives first to conserve credits for critical queries.
|
|
"""
|
|
providers_sorted = sorted(self.providers, key=lambda p: (-p.weight, p.tier.value))
|
|
|
|
# ── Credit pressure: if paid providers are near quota, bump free providers up ──
|
|
credit_pressure = False
|
|
for p in providers_sorted:
|
|
if p.monthly_quota > 0 and p.tier.value in ("paid", "freemium"):
|
|
used = _quota_usage.get(p.name, 0)
|
|
if used > p.monthly_quota * 0.8: # 80% threshold
|
|
credit_pressure = True
|
|
logger.info(
|
|
f"Credit pressure: {p.name} at {used}/{p.monthly_quota} ({used * 100 // p.monthly_quota}%)"
|
|
)
|
|
|
|
if credit_pressure:
|
|
# Re-sort: push free/local providers above paid/freemium near quota
|
|
providers_sorted.sort(key=lambda p: (0 if p.tier.value in ("local", "free_api") else 1, -p.weight))
|
|
|
|
for provider in providers_sorted:
|
|
# Check circuit breaker
|
|
if not _circuit_breakers.get(provider.name, _CircuitBreaker()).can_call():
|
|
logger.debug(f"Circuit breaker open for {provider.name}")
|
|
continue
|
|
|
|
# Check rate limit
|
|
if not _rate_limiters.get(provider.name, _RateLimiter()).can_call():
|
|
logger.debug(f"Rate limit exceeded for {provider.name}")
|
|
continue
|
|
|
|
# Check quota
|
|
if provider.monthly_quota > 0:
|
|
used = _quota_usage.get(provider.name, 0)
|
|
if used >= provider.monthly_quota:
|
|
logger.debug(f"Monthly quota exceeded for {provider.name}")
|
|
continue
|
|
|
|
try:
|
|
# Get API key from env (vault is pool manager, use os.getenv for direct keys)
|
|
api_key = None
|
|
if provider.requires_key and provider.key_env:
|
|
api_key = os.getenv(provider.key_env, "")
|
|
|
|
result = await provider.fetch_fn(api_key=api_key, **kwargs)
|
|
|
|
if result is not None:
|
|
_rate_limiters[provider.name].record_call()
|
|
if provider.monthly_quota > 0:
|
|
_quota_usage[provider.name] = _quota_usage.get(provider.name, 0) + 1
|
|
_circuit_breakers[provider.name].record_success()
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Provider {provider.name} failed: {e}")
|
|
_circuit_breakers[provider.name].record_failure()
|
|
continue
|
|
|
|
return None
|
|
|
|
|
|
# ── Circuit Breaker ────────────────────────────────────────────
|
|
|
|
|
|
class _CircuitBreaker:
|
|
"""Circuit breaker to prevent cascading failures."""
|
|
|
|
def __init__(self, threshold: int = 5, timeout: float = 60.0):
|
|
self.threshold = threshold
|
|
self.timeout = timeout
|
|
self.failures = 0
|
|
self.last_failure = 0.0
|
|
self.open = False
|
|
|
|
def can_call(self) -> bool:
|
|
if self.open:
|
|
if time.time() - self.last_failure > self.timeout:
|
|
self.open = False
|
|
self.failures = 0
|
|
return True
|
|
return False
|
|
return True
|
|
|
|
def record_failure(self) -> None:
|
|
self.failures += 1
|
|
self.last_failure = time.time()
|
|
if self.failures >= self.threshold:
|
|
self.open = True
|
|
|
|
def record_success(self) -> None:
|
|
self.failures = 0
|
|
self.open = False
|
|
|
|
|
|
class _RateLimiter:
|
|
"""Simple rate limiter based on time intervals."""
|
|
|
|
def __init__(self, rps: float = 1.0):
|
|
self.rps = rps
|
|
self.min_interval = 1.0 / rps
|
|
self.last_call = 0.0
|
|
|
|
def can_call(self) -> bool:
|
|
return time.time() - self.last_call >= self.min_interval
|
|
|
|
def record_call(self) -> None:
|
|
self.last_call = time.time()
|
|
|
|
|
|
# ── Shared State ───────────────────────────────────────────────
|
|
|
|
_circuit_breakers: dict[str, _CircuitBreaker] = {}
|
|
_rate_limiters: dict[str, _RateLimiter] = {}
|
|
_quota_usage: dict[str, int] = {}
|
|
|
|
|
|
def reset_state() -> None:
|
|
"""Reset all circuit breakers, rate limiters, and quota tracking.
|
|
|
|
Useful for testing and debugging.
|
|
"""
|
|
global _circuit_breakers, _rate_limiters, _quota_usage
|
|
_circuit_breakers = {}
|
|
_rate_limiters = {}
|
|
_quota_usage = {}
|