# ────────────────────────────── utils/rotator.py ────────────────────────────── import os import itertools from ..logger import get_logger from typing import Optional import httpx logger = get_logger("ROTATOR", __name__) class APIKeyRotator: """ Round-robin API key rotator. - Loads keys from env vars with given prefix (e.g., GEMINI_API_1..5) - get_key() returns current key - rotate() moves to next key - on HTTP 401/429/5xx you should call rotate() and retry (bounded) """ def __init__(self, prefix: str, max_slots: int = 5): self.keys = [] for i in range(1, max_slots + 1): v = os.getenv(f"{prefix}{i}") if v: self.keys.append(v.strip()) if not self.keys: logger.warning(f"No API keys found for prefix {prefix}. Calls will likely fail.") self._cycle = itertools.cycle([""]) else: self._cycle = itertools.cycle(self.keys) self.current = next(self._cycle) def get_key(self) -> Optional[str]: return self.current def rotate(self) -> Optional[str]: self.current = next(self._cycle) logger.info("Rotated API key.") return self.current async def robust_post_json(url: str, headers: dict, payload: dict, rotator: APIKeyRotator, max_retries: int = 5): """ POST JSON with simple retry+rotate on 401/403/429/5xx. Returns json response. """ for attempt in range(max_retries): try: async with httpx.AsyncClient(timeout=60) as client: r = await client.post(url, headers=headers, json=payload) if r.status_code in (401, 403, 429) or (500 <= r.status_code < 600): logger.warning(f"HTTP {r.status_code} from provider. Rotating key and retrying ({attempt+1}/{max_retries})") rotator.rotate() continue r.raise_for_status() return r.json() except Exception as e: logger.warning(f"Request error: {e}. Rotating and retrying ({attempt+1}/{max_retries})") rotator.rotate() raise RuntimeError("Provider request failed after retries.")