from __future__ import annotations from typing import Protocol import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from blackout_notifier.models import Outage, OutageDataError SAAPA_URL = "https://uiapi2.saapa.ir/api/ebills/PlannedBlackoutsReport" EITAAYAR_BASE_URL = "https://eitaayar.ir/api" class ExternalServiceError(RuntimeError): """Raised when an external API fails or returns unsafe data.""" class Notifier(Protocol): def send(self, chat_id: str, text: str) -> None: ... class SaapaClient: def __init__( self, token: str, *, timeout: float, max_events_per_bill: int, session: requests.Session | None = None, ) -> None: self._token = token self._timeout = timeout self._max_events_per_bill = max_events_per_bill self._session = session or _retrying_session({"POST"}) def fetch(self, bill_id: str, from_date: str, to_date: str) -> list[Outage]: try: response = self._session.post( SAAPA_URL, headers={ "Accept": "application/json", "Authorization": f"Bearer {self._token}", }, json={"bill_id": bill_id, "from_date": from_date, "to_date": to_date}, timeout=self._timeout, ) except requests.RequestException as error: raise ExternalServiceError( f"SAAPA request failed for bill {bill_id}: {error}" ) from error if not response.ok: raise ExternalServiceError( f"SAAPA returned HTTP {response.status_code} for bill {bill_id}" ) try: body = response.json() except requests.JSONDecodeError as error: raise ExternalServiceError(f"SAAPA returned invalid JSON for bill {bill_id}") from error if not isinstance(body, dict) or not isinstance(body.get("data"), list): raise ExternalServiceError(f"SAAPA response schema is invalid for bill {bill_id}") raw_events = body["data"] if len(raw_events) > self._max_events_per_bill: raise ExternalServiceError( f"SAAPA returned {len(raw_events)} events for bill {bill_id}; " f"limit is {self._max_events_per_bill}" ) events: list[Outage] = [] seen_numbers: set[str] = set() try: for raw_event in raw_events: event = Outage.from_api(raw_event) if event.outage_number in seen_numbers: raise OutageDataError( f"duplicate outage_number {event.outage_number} in one snapshot" ) seen_numbers.add(event.outage_number) events.append(event) except OutageDataError as error: raise ExternalServiceError(f"Invalid outage for bill {bill_id}: {error}") from error return events class EitaaClient: def __init__( self, token: str, *, timeout: float, session: requests.Session | None = None, ) -> None: self._url = f"{EITAAYAR_BASE_URL}/{token}/sendMessage" self._timeout = timeout self._session = session or _retrying_session({"GET"}) def send(self, chat_id: str, text: str) -> None: try: response = self._session.get( self._url, params={"chat_id": chat_id, "text": text}, timeout=self._timeout, ) except requests.RequestException as error: raise ExternalServiceError( f"Eitaa request failed for chat {chat_id}: {error}" ) from error if not response.ok: raise ExternalServiceError( f"Eitaa returned HTTP {response.status_code} for chat {chat_id}" ) try: body = response.json() except requests.JSONDecodeError as error: raise ExternalServiceError(f"Eitaa returned invalid JSON for chat {chat_id}") from error if not isinstance(body, dict) or body.get("ok") is not True: description = body.get("description") if isinstance(body, dict) else None raise ExternalServiceError( f"Eitaa rejected the message for chat {chat_id}: {description or 'unknown error'}" ) def _retrying_session(methods: set[str]) -> requests.Session: retry = Retry( total=3, connect=3, read=3, status=3, backoff_factor=0.5, status_forcelist=(429, 500, 502, 503, 504), allowed_methods=frozenset(methods), raise_on_status=False, ) session = requests.Session() session.mount("https://", HTTPAdapter(max_retries=retry)) return session