Files
2026-10-16 21:51:41 +02:00

205 lines
7.7 KiB
Python

# streaming_providers/providers/hrti/api.py
"""
Shared API-call helper for the HRTi managers (one place for the error
contract instead of a try/except in every method).
* endpoint URL from the lazily loaded dynamic config
* authorization/device headers from the session (a fresh token per request)
* transport / HTTP-status errors become typed errors (base.errors);
works whether the HTTP manager raises or returns the response
* HTTP 401 / AuthError -> ONE session refresh + exactly one retry
* body ErrorCode == 4 (the server no longer knows this device session --
typically a device id loaded from persisted storage that the server has
since expired) -> ONE device re-registration through the session + ONE
retry; a request that STILL answers ErrorCode 4 raises AuthError, also
for optional endpoints (a broken session is not "nothing there")
* other API body ``ErrorCode != 0``: raises ServerError for required
endpoints, returns None for optional ones (``required=False``)
Worst case per call: 4 requests, 2 session refreshes, 1 device
re-registration. The state change lives in the session
(reregister_device), which locks and re-checks -- concurrent callers that
all fail with the same stale device id must not stampede RegisterDevice.
"""
import json
from typing import Any, Dict, Optional, Tuple
from ...base.errors import (
AuthError,
ProviderError,
ServerError,
TransportError,
default_error_for_status_heuristic,
)
from ...base.utils.logger import logger
from .constants import HRTiConfig
#: Body-level error code: the server no longer knows this device session.
#: Observed with a device id loaded from persisted storage after the
#: server-side registration had expired. Recoverable exactly once per call
#: (see api_post); a surviving ErrorCode 4 is an AuthError.
DEVICE_SESSION_INVALID_CODE = 4
def _snippet(response: Any) -> str:
try:
return (response.text or "")[:200]
except Exception: # response object without text
return ""
def typed_error_for(exc: BaseException, url: Optional[str] = None) -> ProviderError:
"""Typed error for an exception raised by the HTTP layer: an exception that
carries a response is mapped by its status, anything else is a transport
fault."""
failed = getattr(exc, "response", None)
status = getattr(failed, "status_code", None)
if isinstance(status, int):
return default_error_for_status_heuristic(
status, f"HRTi HTTP {status}: {exc}", url=url, body_snippet=_snippet(failed)
)
return TransportError(f"HRTi request failed: {exc}", url=url)
def _post_json(http_manager: Any, url: str, headers: Dict[str, str], payload: Dict) -> Dict:
try:
response = http_manager.post(
url, operation="api", headers=headers, data=json.dumps(payload)
)
except ProviderError:
raise
except Exception as exc: # HTTP manager raising for 4xx/5xx or transport faults
raise typed_error_for(exc, url) from exc
status = getattr(response, "status_code", None)
if isinstance(status, int) and status >= 400:
raise default_error_for_status_heuristic(
status, f"HRTi HTTP {status}", url=url, body_snippet=_snippet(response)
)
try:
data = response.json()
except ValueError as exc:
raise ServerError("HRTi: response is not valid JSON", url=url) from exc
if not isinstance(data, dict):
raise ServerError("HRTi: unexpected response shape", url=url)
return data
def _post_with_auth_recovery(
*,
http_manager: Any,
session: Any,
config: HRTiConfig,
url: str,
endpoint_key: str,
payload: Dict,
referer_path: str,
) -> Tuple[Dict, Optional[str]]:
"""POST + parse with ONE session refresh on HTTP 401 / AuthError.
Returns ``(parsed body, device id used for the request that produced
it)``. The device id is captured at header-build time (token first, then
device id -- v1 evaluation order) so the ErrorCode-4 recovery can hand
the session the id that actually failed (concurrency re-check in
HRTiSession.reregister_device).
"""
data: Dict = {}
device_id: Optional[str] = None
for attempt in (1, 2):
# Token and device state are read OUTSIDE the try: a login failure is
# already a typed error and must not trigger the 401 recovery.
token = session.get_access_token()
device_id = session.device_id
headers = config.api_headers(
token=token,
device_id=device_id,
ip_address=session.ip_address,
referer_path=referer_path,
)
try:
data = _post_json(http_manager, url, headers, payload)
break
except AuthError:
if attempt == 1:
logger.info(f"HRTi: {endpoint_key} answered 401, refreshing the session once")
session.refresh()
continue
raise
return data, device_id
def api_post(
*,
http_manager: Any,
session: Any,
config: HRTiConfig,
endpoint_key: str,
payload: Dict,
referer_path: str = "/",
required: bool = True,
) -> Optional[Any]:
"""POST to a named endpoint and return the ``Result`` field.
Recovery ladder, each step at most ONCE per call:
1. HTTP 401 / AuthError -> session.refresh() + one retry
2. body ErrorCode == 4 -> session.reregister_device() + one retry
(the retry runs through step 1 again, so a token that died together
with the old device registration is refreshed there)
A failure DURING recovery (re-registration or the retry request) raises
its typed error and propagates. ErrorCode 4 that survives the
re-registration raises AuthError -- for optional endpoints too: with the
old code a stale device id silently emptied EPG and every other optional
endpoint. All other ErrorCode != 0 follow the required/optional rule.
"""
url = config.endpoint(endpoint_key)
data, device_id_used = _post_with_auth_recovery(
http_manager=http_manager,
session=session,
config=config,
url=url,
endpoint_key=endpoint_key,
payload=payload,
referer_path=referer_path,
)
if data.get("ErrorCode") == DEVICE_SESSION_INVALID_CODE:
logger.warning(
f"HRTi: {endpoint_key} answered ErrorCode {DEVICE_SESSION_INVALID_CODE} "
f"(invalid device/session) for device id {device_id_used!r}; "
f"re-registering the device once"
)
session.reregister_device(failed_device_id=device_id_used)
data, _ = _post_with_auth_recovery(
http_manager=http_manager,
session=session,
config=config,
url=url,
endpoint_key=endpoint_key,
payload=payload,
referer_path=referer_path,
)
if data.get("ErrorCode") == DEVICE_SESSION_INVALID_CODE:
# NOTE: constructor kwargs of base.errors are not fully verified
# (README §8); add url= here too if AuthError supports it
# (ServerError / TransportError do).
raise AuthError(
"HRTi: device session still invalid (ErrorCode "
f"{DEVICE_SESSION_INVALID_CODE}) after re-registration"
)
code = data.get("ErrorCode", 0)
if code not in (0, None):
description = data.get("ErrorDescription")
if required:
raise ServerError(
f"HRTi API error for {endpoint_key}: [{code}] {description}",
url=url,
code=str(code),
)
logger.debug(f"HRTi optional endpoint {endpoint_key}: [{code}] {description}")
return None
return data.get("Result")