Implemented the full clean rewrite for Gitea
This commit is contained in:
136
src/blackout_notifier/clients.py
Normal file
136
src/blackout_notifier/clients.py
Normal file
@@ -0,0 +1,136 @@
|
||||
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, 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,
|
||||
chat_id: str,
|
||||
*,
|
||||
timeout: float,
|
||||
session: requests.Session | None = None,
|
||||
) -> None:
|
||||
self._url = f"{EITAAYAR_BASE_URL}/{token}/sendMessage"
|
||||
self._chat_id = chat_id
|
||||
self._timeout = timeout
|
||||
self._session = session or _retrying_session({"GET"})
|
||||
|
||||
def send(self, text: str) -> None:
|
||||
try:
|
||||
response = self._session.get(
|
||||
self._url,
|
||||
params={"chat_id": self._chat_id, "text": text},
|
||||
timeout=self._timeout,
|
||||
)
|
||||
except requests.RequestException as error:
|
||||
raise ExternalServiceError(f"Eitaa request failed: {error}") from error
|
||||
if not response.ok:
|
||||
raise ExternalServiceError(f"Eitaa returned HTTP {response.status_code}")
|
||||
try:
|
||||
body = response.json()
|
||||
except requests.JSONDecodeError as error:
|
||||
raise ExternalServiceError("Eitaa returned invalid JSON") 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: {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
|
||||
Reference in New Issue
Block a user