139 lines
4.8 KiB
Python
139 lines
4.8 KiB
Python
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
|