Files
daily-blackout-check/src/blackout_notifier/clients.py
Meghdad ea03d84901
All checks were successful
Test blackout notifier / test (push) Successful in 4s
Hourly blackout check / check (push) Successful in 5s
style: apply Ruff formatting
2026-07-22 03:20:01 +03:30

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