""" Shared SEC EDGAR HTTP client with retry, backoff, throttling, and caching. Extracted from etf_holdings_fetcher.py to be reused by all SEC-related services. """ import time as _time import asyncio import aiohttp import hashlib import json import os import random import logging from typing import Dict, Optional from app.core.config import settings logger = logging.getLogger(__name__) def _is_sec_block_page(text: str) -> bool: if not text: return False tl = text.lower() if "your request originates from an undeclared automated tool" in tl: return True if "sec.gov | your request originates" in tl: return True if "reference id:" in tl and "sec.gov" in tl: return True return False class SECHttpClient: """Shared HTTP client for SEC EDGAR API requests. Features: - asyncio.Semaphore(2) concurrent request limit - 6 retries with 1.8x exponential backoff + random jitter - 429 Retry-After header respect - SEC block page detection - Deadline-aware timeouts - Disk cache (/tmp/stock_oracle_sec_cache/, SHA-256 keyed) - In-memory cache (_json_cache, _text_cache) """ def __init__(self, user_agent_name: str = "Stock Oracle"): self.sec_base_data = "https://data.sec.gov" self.sec_base_www = "https://www.sec.gov" self.http_timeout = aiohttp.ClientTimeout(total=12) self._req_sem = asyncio.Semaphore(2) self._text_cache: Dict[str, str] = {} self._json_cache: Dict[str, dict] = {} self._cache_dir = "/tmp/stock_oracle_sec_cache" try: os.makedirs(self._cache_dir, exist_ok=True) except Exception: pass self._user_agent = f"{user_agent_name} ({settings.SEC_EMAIL})" self._deadline: Optional[float] = None # ------------------------------------------------------------------ # Deadline management # ------------------------------------------------------------------ def set_deadline(self, seconds_from_now: float) -> None: self._deadline = _time.monotonic() + seconds_from_now def clear_deadline(self) -> None: self._deadline = None @property def deadline(self) -> Optional[float]: return self._deadline @deadline.setter def deadline(self, value: Optional[float]) -> None: self._deadline = value def remaining_time(self) -> Optional[float]: if self._deadline is None: return None return max(0.0, self._deadline - _time.monotonic()) def is_deadline_exceeded(self) -> bool: if self._deadline is None: return False return _time.monotonic() >= self._deadline # ------------------------------------------------------------------ # Cache helpers # ------------------------------------------------------------------ def _cache_path(self, url: str) -> str: h = hashlib.sha256(url.encode("utf-8")).hexdigest() return os.path.join(self._cache_dir, h) # ------------------------------------------------------------------ # CIK lookup # ------------------------------------------------------------------ async def get_company_cik(self, ticker: str) -> Optional[str]: """Look up zero-padded 10-digit CIK for a ticker.""" url = f"{self.sec_base_www}/files/company_tickers.json" try: data = await self.fetch_json(url) for _key, company_info in data.items(): if company_info.get("ticker", "").upper() == ticker.upper(): cik_str = str(company_info.get("cik_str", "")).zfill(10) logger.info(f"Found CIK {cik_str} for ticker {ticker}") return cik_str logger.warning(f"Ticker {ticker} not found in SEC mapping") return None except Exception as e: logger.error(f"Error fetching CIK for {ticker}: {e}") return None # ------------------------------------------------------------------ # HTTP fetch methods # ------------------------------------------------------------------ async def fetch_json(self, url: str) -> dict: """Fetch JSON with retry, backoff, and caching.""" # In-memory cache if url in self._json_cache: return self._json_cache[url] # Disk cache try: cp = self._cache_path(url) + ".json" if os.path.exists(cp): ttl_sec = max(3600, settings.SEC_DATA_REFRESH_HOURS * 3600) if _time.time() - os.path.getmtime(cp) <= ttl_sec: with open(cp, "r", encoding="utf-8") as f: data = json.load(f) self._json_cache[url] = data return data except Exception: pass attempts = 6 backoff = 1.0 last_exc = None for _i in range(attempts): now = _time.monotonic() if self._deadline is not None and now >= self._deadline: break req_timeout = self.http_timeout if self._deadline is not None: remaining = max(0.0, self._deadline - now) if remaining < 0.25: break req_timeout = aiohttp.ClientTimeout( total=min(remaining, getattr(self.http_timeout, "total", 12)) ) async with self._req_sem: try: async with aiohttp.ClientSession( timeout=req_timeout, headers={ "User-Agent": self._user_agent, "Accept": "application/json", }, ) as session: async with session.get(url, timeout=req_timeout) as resp: if resp.status == 429: retry_after = resp.headers.get("Retry-After") delay = ( float(retry_after) if retry_after and retry_after.isdigit() else backoff ) if self._deadline is not None: remaining = max(0.0, self._deadline - _time.monotonic()) delay = min(delay, max(0.0, remaining - 0.05)) await asyncio.sleep( max(0.0, delay) + random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0) ) backoff *= 1.8 continue if 500 <= resp.status < 600: delay = backoff if self._deadline is not None: remaining = max(0.0, self._deadline - _time.monotonic()) delay = min(delay, max(0.0, remaining - 0.05)) await asyncio.sleep( max(0.0, delay) + random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0) ) backoff *= 1.8 continue resp.raise_for_status() data = await resp.json() # Cache successful response try: with open(self._cache_path(url) + ".json", "w", encoding="utf-8") as f: json.dump(data, f) except Exception: pass self._json_cache[url] = data return data except Exception as e: last_exc = e delay = backoff if self._deadline is not None: remaining = max(0.0, self._deadline - _time.monotonic()) delay = min(delay, max(0.0, remaining - 0.05)) await asyncio.sleep( max(0.0, delay) + random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0) ) backoff *= 1.8 continue raise last_exc if last_exc else RuntimeError("Failed to fetch JSON") async def fetch_text(self, url: str, accept: str = "text/html") -> str: """Fetch text content with retry, backoff, block page detection, and caching.""" # In-memory cache if url in self._text_cache: return self._text_cache[url] # Disk cache try: cp = self._cache_path(url) + ".txt" if os.path.exists(cp): ttl_sec = max(3600, settings.SEC_DATA_REFRESH_HOURS * 3600) if _time.time() - os.path.getmtime(cp) <= ttl_sec: with open(cp, "r", encoding="utf-8") as f: text = f.read() if _is_sec_block_page(text): try: os.remove(cp) except Exception: pass else: self._text_cache[url] = text return text except Exception: pass attempts = 6 backoff = 1.0 last_exc = None for _i in range(attempts): now = _time.monotonic() if self._deadline is not None and now >= self._deadline: break req_timeout = self.http_timeout if self._deadline is not None: remaining = max(0.0, self._deadline - now) if remaining < 0.25: break req_timeout = aiohttp.ClientTimeout( total=min(remaining, getattr(self.http_timeout, "total", 12)) ) async with self._req_sem: try: async with aiohttp.ClientSession( timeout=req_timeout, headers={ "User-Agent": self._user_agent, "Accept": accept, }, ) as session: async with session.get(url, timeout=req_timeout) as resp: if resp.status == 429: retry_after = resp.headers.get("Retry-After") delay = ( float(retry_after) if retry_after and retry_after.isdigit() else backoff ) if self._deadline is not None: remaining = max(0.0, self._deadline - _time.monotonic()) delay = min(delay, max(0.0, remaining - 0.05)) await asyncio.sleep( max(0.0, delay) + random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0) ) backoff *= 1.8 continue if 500 <= resp.status < 600: delay = backoff if self._deadline is not None: remaining = max(0.0, self._deadline - _time.monotonic()) delay = min(delay, max(0.0, remaining - 0.05)) await asyncio.sleep( max(0.0, delay) + random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0) ) backoff *= 1.8 continue resp.raise_for_status() text = await resp.text() if _is_sec_block_page(text): last_exc = RuntimeError("SEC_BLOCKED") delay = backoff * 2.0 if self._deadline is not None: remaining = max(0.0, self._deadline - _time.monotonic()) delay = min(delay, max(0.0, remaining - 0.05)) await asyncio.sleep( max(0.0, delay) + random.uniform(0.0, delay * 0.5 if delay > 0 else 0.0) ) backoff *= 2.0 continue # Cache successful response self._text_cache[url] = text try: with open(self._cache_path(url) + ".txt", "w", encoding="utf-8") as f: f.write(text) except Exception: pass return text except Exception as e: last_exc = e delay = backoff if self._deadline is not None: remaining = max(0.0, self._deadline - _time.monotonic()) delay = min(delay, max(0.0, remaining - 0.05)) await asyncio.sleep( max(0.0, delay) + random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0) ) backoff *= 1.8 continue raise last_exc if last_exc else RuntimeError("Failed to fetch text")