""" fetchers/base.py — Abstract base class for all macro data fetchers. Provides retry logic, rate limiting, and a common interface. """ from abc import ABC, abstractmethod from datetime import date as Date from typing import Optional import logging import time import requests class BaseFetcher(ABC): """Abstract base for all macro data fetchers.""" def __init__(self, timeout: int = 30, max_retries: int = 3): self.timeout = timeout self.max_retries = max_retries self.logger = logging.getLogger(self.__class__.__name__) def _get(self, url: str, params: Optional[dict] = None, headers: Optional[dict] = None) -> dict: """GET with retry and exponential backoff.""" for attempt in range(self.max_retries): try: resp = requests.get( url, params=params, headers=headers, timeout=self.timeout ) resp.raise_for_status() return resp.json() except requests.RequestException as e: wait = 2 ** attempt self.logger.warning( f"Request failed (attempt {attempt+1}/{self.max_retries}): {e}. " f"Retrying in {wait}s" ) if attempt < self.max_retries - 1: time.sleep(wait) else: raise def _get_raw(self, url: str, params: Optional[dict] = None, headers: Optional[dict] = None) -> bytes: """GET raw bytes with retry (for non-JSON endpoints).""" for attempt in range(self.max_retries): try: resp = requests.get( url, params=params, headers=headers, timeout=self.timeout ) resp.raise_for_status() return resp.content except requests.RequestException as e: wait = 2 ** attempt if attempt < self.max_retries - 1: time.sleep(wait) else: raise @abstractmethod def fetch(self, target_date: Optional[Date] = None) -> list[dict]: """Fetch raw data. Returns list of record dicts.""" ... @abstractmethod def store(self, db_path: str, records: list[dict]) -> int: """Store raw records into SQLite. Returns count of new rows.""" ...