Files
script.service.ultimate/lib/streaming_providers/base/errors.py
T
2026-10-03 14:35:57 +02:00

262 lines
8.9 KiB
Python

# streaming_providers/base/errors.py
"""
Shared provider error hierarchy.
Rationale
---------
Every provider raises its own exception types for the same handful of
conditions: expired auth, geo-block, entitlement denial, content removed,
rate limits, server faults, playback restrictions, catchup-required items.
Downstream code (operations layer, Kodi plugin, UI) then has to special-case
per provider.
This module provides one hierarchy that all providers can subclass. Providers
MAY keep their own exception class names (existing callers can still catch
those); the only requirement is that the provider's classes inherit from the
appropriate base here.
This is a *convention*, not a mechanical enforcement. Nothing prevents a
provider from raising a bare Exception. But if a provider raises from this
hierarchy, callers get uniform handling for free.
Usage in a provider:
from ...base.errors import AuthError, GeoBlockError
class MyVodAuthError(AuthError): ...
class MyVodGeoBlockError(GeoBlockError): ...
Usage in a caller:
from ...base.errors import AuthError, GeoBlockError, ProviderError
try:
manifest = provider.get_manifest(content_id)
except AuthError:
... # refresh credentials / prompt for re-login
except GeoBlockError:
... # not available in your region
except ProviderError as e:
... # generic fallback; e.code may carry a provider-specific code
Error codes
-----------
Some providers (Discovery+) emit machine-readable codes alongside the
exception type. This base supports an optional `code` attribute. Providers
that don't use codes leave it None.
"""
from __future__ import annotations
from typing import Any, Optional, Tuple
class ProviderError(Exception):
"""
Base class for all provider errors.
Every subclass accepts (message, *, status=None, url=None, code=None) and
stores them as attributes so callers can inspect without re-parsing the
message string.
Note on pickling / copy.deepcopy:
Subclasses are allowed to have different __init__ signatures
(e.g. ChannelNotFoundError(channel_id)) and to carry extra payload
attributes. __reduce__ below reconstructs via __new__ and restores
__dict__ as state, so it works regardless of the subclass's
constructor shape and preserves every attribute.
"""
def __init__(
self,
message: str,
*,
status: Optional[int] = None,
url: Optional[str] = None,
code: Optional[str] = None,
) -> None:
super().__init__(message)
self.status = status
self.url = url
self.code = code
def __repr__(self) -> str:
msg = self.args[0] if self.args else ""
parts = [self.__class__.__name__, f"({msg!r}"]
if self.status is not None:
parts.append(f", status={self.status}")
if self.code is not None:
parts.append(f", code={self.code!r}")
parts.append(")")
return "".join(parts)
def __reduce__(self) -> Tuple[Any, ...]:
"""
Preserve ALL attributes across pickle / copy.deepcopy.
Returns a 3-tuple (callable, args, state). The annotation is
Tuple[Any, ...] because __reduce__ can return a 2-tuple or a
3-tuple depending on whether state is provided; the base
object.__reduce__ signature reflects this.
Exception.__reduce__ uses args only, which drops keyword-only fields
(status, url, code) and any subclass payload. Reconstruction goes
through __new__ so the subclass's __init__ is not called -- this
means subclass constructors with different signatures (e.g.
PlaybackRestrictedException(reason, error_code)) work fine, and
__dict__ is restored as-is.
"""
return (
_rebuild_provider_error,
(self.__class__, self.args),
self.__dict__.copy(),
)
def _rebuild_provider_error(
cls: type, args: Tuple
) -> "ProviderError":
"""
Reconstruction helper for ProviderError.__reduce__.
Deliberately does not call cls(...). Creates a bare instance and sets
args; pickle then restores __dict__ as state, so every attribute the
original had comes back regardless of the subclass constructor.
"""
exc = cls.__new__(cls)
exc.args = tuple(args)
return exc
# ---------------------------------------------------------------------------
# Auth / session
# ---------------------------------------------------------------------------
class AuthError(ProviderError):
"""401-style failure: token expired, invalid, or missing."""
class CredentialsError(AuthError):
"""Invalid username/password (as opposed to a stale token)."""
class SessionExpiredError(AuthError):
"""Server-side session is gone; a full re-login is required."""
# ---------------------------------------------------------------------------
# Access control
# ---------------------------------------------------------------------------
class GeoBlockError(ProviderError):
"""Content is not available in the caller's region."""
class EntitlementError(ProviderError):
"""Authenticated, but not entitled to this content."""
class AccountRestrictedError(EntitlementError):
"""Account-level gate (e.g. VOD disabled for the whole account)."""
class PlaybackRestrictedError(ProviderError):
"""Playback is refused for a reason not covered above."""
# ---------------------------------------------------------------------------
# Content lookup
# ---------------------------------------------------------------------------
class NotFoundError(ProviderError):
"""Content is known to the provider but has been removed.
At the manager top level, "this manager doesn't handle that content_id"
is signalled by returning None/[] -- not by raising NotFoundError. See
the "None vs exception" section in providers/_template/README.md.
NotFoundError is for the case where the provider *knows* the content
belongs in its domain but has been removed (e.g. a VOD detail fetch
returns 404 for an id that was recently listed). The orchestrator's
router will try other managers, then re-raise this if nobody resolves.
"""
class BadRequestError(ProviderError):
"""400 -- the request was malformed for the endpoint called.
Used as a routing signal by providers that guess endpoint shape from an
opaque content_id (e.g. Magenta's page-vs-component dispatch). The
orchestrator's router does NOT swallow this -- providers that need that
behavior override handles_content_id() instead.
"""
# ---------------------------------------------------------------------------
# Transport / server
# ---------------------------------------------------------------------------
class RateLimitError(ProviderError):
"""429 -- caller should back off and retry."""
class ServerError(ProviderError):
"""5xx -- retryable."""
class TransportError(ProviderError):
"""Connection-level failure (DNS, TLS, timeout) -- no HTTP status."""
# ---------------------------------------------------------------------------
# Flow control
# ---------------------------------------------------------------------------
class CatchupRequiredError(ProviderError):
"""This 'VOD' item is actually a catch-up entry from a linear channel."""
class NotImplementedYetError(ProviderError):
"""Feature captured but not yet wired up."""
class ConfigurationError(ProviderError):
"""Provider is misconfigured."""
# ---------------------------------------------------------------------------
# HTTP status -> error class heuristic
# ---------------------------------------------------------------------------
def default_error_for_status_heuristic(
status: int,
message: str,
*,
url: Optional[str] = None,
body_snippet: str = "",
) -> ProviderError:
"""
Heuristic mapping from HTTP status to ProviderError subclass.
This is a heuristic, not a contract. Providers with their own error
classification should construct the specific error directly rather than
relying on this. The 403 branch in particular uses a substring match on
body_snippet and is intentionally shallow so providers do not depend on
it accidentally.
"""
if status == 400:
return BadRequestError(message, status=status, url=url)
if status == 401:
return AuthError(message, status=status, url=url)
if status == 403:
lower = body_snippet.lower()
if "geo" in lower or "region" in lower:
return GeoBlockError(message, status=status, url=url)
return EntitlementError(message, status=status, url=url)
if status == 404:
return NotFoundError(message, status=status, url=url)
if status == 429:
return RateLimitError(message, status=status, url=url)
if 500 <= status < 600:
return ServerError(message, status=status, url=url)
return ProviderError(message, status=status, url=url)