"""
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 contextvars
import aiohttp
import hashlib
import json
import os
import random
import logging
from typing import Dict, Optional
from app.core.config import settings
# Per-asyncio-task deadline — isolates concurrent requests from each other
_deadline_var: contextvars.ContextVar[Optional[float]] = contextvars.ContextVar(
"sec_http_deadline", default=None
)
logger = logging.getLogger(__name__)
# Only cache small responses in memory; large responses go to disk cache only
_MAX_MEMORY_CACHE_BYTES = 1 * 1024 * 1024 # 1MB
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 _TokenBucket:
"""Async token bucket rate limiter.
Allows up to `rate` requests per second with a burst capacity of `capacity`.
"""
def __init__(self, rate: float, capacity: float) -> None:
self._rate = rate
self._capacity = capacity
self._tokens = capacity
self._last_refill = _time.monotonic()
self._lock = asyncio.Lock()
async def acquire(self) -> None:
while True:
async with self._lock:
now = _time.monotonic()
elapsed = now - self._last_refill
self._tokens = min(self._capacity, self._tokens + elapsed * self._rate)
self._last_refill = now
if self._tokens >= 1.0:
self._tokens -= 1.0
return
wait_time = (1.0 - self._tokens) / self._rate
await asyncio.sleep(wait_time)
class SECHttpClient:
"""Shared HTTP client for SEC EDGAR API requests.
Features:
- asyncio.Semaphore(2) concurrent request limit
- Token bucket rate limiter (10 req/sec)
- 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)
"""
# Max total pending (waiting + active) SEC HTTP requests across all callers.
# Requests beyond this limit are rejected immediately to protect the event loop.
_MAX_PENDING: int = 50
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._rate_limiter = _TokenBucket(rate=8.0, capacity=8.0)
self._pending: int = 0
self._text_cache: Dict[str, str] = {}
self._json_cache: Dict[str, dict] = {}
# Negative cache for tickers with no SEC CIK — prevents bulk A-Z scans
# from scanning the 10MB tickers map hundreds of times per second.
self._cik_negative_cache: Dict[str, float] = {}
self._cik_negative_ttl: float = 3600.0 # 1 hour
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._session: Optional[aiohttp.ClientSession] = None
async def _get_session(self) -> aiohttp.ClientSession:
"""Get or create a persistent session."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
timeout=self.http_timeout,
headers={"User-Agent": self._user_agent},
)
return self._session
async def close(self) -> None:
"""Close the persistent session."""
if self._session and not self._session.closed:
await self._session.close()
self._session = None
# ------------------------------------------------------------------
# Deadline management
# ------------------------------------------------------------------
def set_deadline(self, seconds_from_now: float) -> None:
_deadline_var.set(_time.monotonic() + seconds_from_now)
def clear_deadline(self) -> None:
_deadline_var.set(None)
@property
def deadline(self) -> Optional[float]:
return _deadline_var.get()
@deadline.setter
def deadline(self, value: Optional[float]) -> None:
_deadline_var.set(value)
def remaining_time(self) -> Optional[float]:
dl = _deadline_var.get()
if dl is None:
return None
return max(0.0, dl - _time.monotonic())
def is_deadline_exceeded(self) -> bool:
dl = _deadline_var.get()
if dl is None:
return False
return _time.monotonic() >= dl
# ------------------------------------------------------------------
# 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."""
tkr = ticker.upper()
now = _time.monotonic()
# Negative-cache hit: don't scan 10MB dict for known-missing tickers
expiry = self._cik_negative_cache.get(tkr)
if expiry is not None and now < expiry:
return None
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() == tkr:
cik_str = str(company_info.get("cik_str", "")).zfill(10)
logger.info(f"Found CIK {cik_str} for ticker {ticker}")
self._cik_negative_cache.pop(tkr, None)
return cik_str
logger.warning(f"Ticker {ticker} not found in SEC mapping")
self._cik_negative_cache[tkr] = now + self._cik_negative_ttl
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, skip_cache: bool = False) -> dict:
"""Fetch JSON with retry, backoff, and caching.
Args:
skip_cache: If True, bypass in-memory and disk cache reads (still writes
to cache after a successful fetch so subsequent calls benefit).
"""
# In-memory cache
if not skip_cache and url in self._json_cache:
return self._json_cache[url]
# Disk cache
if not skip_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
# Backpressure: reject immediately if too many SEC requests are already pending.
# This prevents thousands of coroutines from stacking up in the event loop,
# which would starve health checks and other endpoints.
if self._pending >= self._MAX_PENDING:
raise RuntimeError(
f"SEC request queue full ({self._pending}/{self._MAX_PENDING} pending)"
)
self._pending += 1
try:
attempts = 6
backoff = 1.0
last_exc = None
for _i in range(attempts):
now = _time.monotonic()
dl = _deadline_var.get()
if dl is not None and now >= dl:
break
req_timeout = self.http_timeout
if dl is not None:
remaining = max(0.0, dl - now)
if remaining < 0.25:
break
req_timeout = aiohttp.ClientTimeout(
total=min(remaining, getattr(self.http_timeout, "total", 12))
)
async with self._req_sem:
await self._rate_limiter.acquire()
try:
session = await self._get_session()
async with session.get(url, timeout=req_timeout, headers={"Accept": "application/json"}) 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
)
dl = _deadline_var.get()
if dl is not None:
remaining = max(0.0, dl - _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
dl = _deadline_var.get()
if dl is not None:
remaining = max(0.0, dl - _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()
# SEC's filing index.json (used by Form 4 ingest) is
# served with text/html content-type, so disable the
# mimetype check.
data = await resp.json(content_type=None)
# 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 aiohttp.ClientResponseError as e:
if 400 <= e.status < 500:
raise # 4xx: no retry, raise immediately
last_exc = e
delay = backoff
dl = _deadline_var.get()
if dl is not None:
remaining = max(0.0, dl - _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
except Exception as e:
last_exc = e
delay = backoff
dl = _deadline_var.get()
if dl is not None:
remaining = max(0.0, dl - _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")
finally:
self._pending -= 1
# ------------------------------------------------------------------
# CIK reverse-lookup (numeric CIK → ticker)
# ------------------------------------------------------------------
async def get_ticker_for_cik(self, cik: str) -> Optional[str]:
"""Return the primary ticker for a numeric CIK, or None if not found."""
cik_str = str(int(cik)).zfill(10)
url = f"{self.sec_base_www}/files/company_tickers.json"
try:
data = await self.fetch_json(url)
for _, info in data.items():
if str(info.get("cik_str", "")).zfill(10) == cik_str:
return info.get("ticker", "").upper() or None
except Exception as e:
logger.error(f"Error in get_ticker_for_cik({cik}): {e}")
return None
# ------------------------------------------------------------------
# Full-index / daily-index helpers
# ------------------------------------------------------------------
async def fetch_quarterly_company_idx(self, year: int, quarter: int) -> str:
"""Fetch the full-index company.idx for a given year/quarter (fixed-width text)."""
url = f"{self.sec_base_www}/Archives/edgar/full-index/{year}/QTR{quarter}/company.idx"
return await self.fetch_text(url, accept="text/plain")
async def fetch_daily_index(self, date_str: str) -> str:
"""Fetch the daily company index for a given date string (YYYYMMDD).
Short TTL: the disk cache key is unique per date, so yesterday's file is
cached forever. Today's file may be growing — callers should use skip_cache
if they need the freshest data.
"""
from datetime import datetime
dt = datetime.strptime(date_str, "%Y%m%d")
quarter = (dt.month - 1) // 3 + 1
url = f"{self.sec_base_www}/Archives/edgar/daily-index/{dt.year}/QTR{quarter}/company.{date_str}.idx"
return await self.fetch_text(url, accept="text/plain")
async def fetch_form345_zip(self, year: int, quarter: int, dest_path: str) -> str:
"""Stream the form345.zip full-index archive to dest_path. Returns dest_path."""
import os
url = f"{self.sec_base_www}/Archives/edgar/full-index/{year}/QTR{quarter}/form.idx"
# form.idx is a fixed-width index file filtered to form types; for zip download
# use the actual form345.zip:
zip_url = f"{self.sec_base_www}/Archives/edgar/full-index/{year}/QTR{quarter}/form345.zip"
if self._pending >= self._MAX_PENDING:
raise RuntimeError(f"SEC request queue full ({self._pending}/{self._MAX_PENDING} pending)")
self._pending += 1
try:
import aiohttp as _aiohttp
session = await self._get_session()
async with self._req_sem:
await self._rate_limiter.acquire()
async with session.get(zip_url, timeout=aiohttp.ClientTimeout(total=300)) as resp:
resp.raise_for_status()
os.makedirs(os.path.dirname(dest_path) or ".", exist_ok=True)
with open(dest_path, "wb") as f:
async for chunk in resp.content.iter_chunked(65536):
f.write(chunk)
return dest_path
finally:
self._pending -= 1
async def fetch_text(self, url: str, accept: str = "text/html", max_bytes: Optional[int] = None) -> 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:
if len(text) <= _MAX_MEMORY_CACHE_BYTES:
self._text_cache[url] = text
return text
except Exception:
pass
# Backpressure: reject immediately if too many SEC requests are already pending.
if self._pending >= self._MAX_PENDING:
raise RuntimeError(
f"SEC request queue full ({self._pending}/{self._MAX_PENDING} pending)"
)
self._pending += 1
try:
attempts = 6
backoff = 1.0
last_exc = None
for _i in range(attempts):
now = _time.monotonic()
dl = _deadline_var.get()
if dl is not None and now >= dl:
break
req_timeout = self.http_timeout
if dl is not None:
remaining = max(0.0, dl - now)
if remaining < 0.25:
break
req_timeout = aiohttp.ClientTimeout(
total=min(remaining, getattr(self.http_timeout, "total", 12))
)
async with self._req_sem:
await self._rate_limiter.acquire()
try:
session = await self._get_session()
async with session.get(url, timeout=req_timeout, headers={"Accept": accept}) 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
)
dl = _deadline_var.get()
if dl is not None:
remaining = max(0.0, dl - _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
dl = _deadline_var.get()
if dl is not None:
remaining = max(0.0, dl - _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()
if max_bytes is not None:
cl = resp.content_length
if cl is not None and cl > max_bytes:
raise ValueError(
f"Response too large ({cl} bytes > {max_bytes} limit)"
)
raw = await resp.content.read(max_bytes + 1)
if len(raw) > max_bytes:
raise ValueError(
f"Response exceeded {max_bytes} byte limit"
)
# Parse charset from Content-Type header directly;
# resp.get_encoding() needs resp.read() body which we didn't call.
ctype_hdr = resp.headers.get("Content-Type", "")
encoding = "utf-8"
for part in ctype_hdr.split(";"):
part = part.strip()
if part.lower().startswith("charset="):
encoding = part[8:].strip().strip('"') or "utf-8"
break
text = raw.decode(encoding, errors="replace")
else:
text = await resp.text()
if _is_sec_block_page(text):
last_exc = RuntimeError("SEC_BLOCKED")
delay = backoff * 2.0
dl = _deadline_var.get()
if dl is not None:
remaining = max(0.0, dl - _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
if len(text) <= _MAX_MEMORY_CACHE_BYTES:
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 aiohttp.ClientResponseError as e:
if 400 <= e.status < 500:
raise # 4xx: no retry, raise immediately
last_exc = e
delay = backoff
dl = _deadline_var.get()
if dl is not None:
remaining = max(0.0, dl - _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
except ValueError:
raise # Size limit exceeded: no retry (size won't change)
except Exception as e:
last_exc = e
delay = backoff
dl = _deadline_var.get()
if dl is not None:
remaining = max(0.0, dl - _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")
finally:
self._pending -= 1