"""Structured exception hierarchy for tidalpy.
Every error carries a machine-readable :meth:`to_dict` payload so hosts
(Django models, Celery task rows, logs) can persist failures without
parsing human-readable text. The legacy integration had to guess what
happened from CLI exit codes and stderr fragments; this hierarchy makes
the failure mode explicit and testable.
Error taxonomy
--------------
- :class:`ConfigError` bad configuration, fail fast before network I/O
- :class:`AuthError` missing/expired/unusable credentials
- :class:`RateLimitError` TIDAL kept answering 429 beyond the wait budget
- :class:`NotFoundError` resource id does not exist (either API version)
- :class:`RegionLockedError` content unavailable in the account's country
- :class:`PreviewOnlyError` account can only hear 30 s previews (free tier)
- :class:`QualityUnavailableError` no stream at any attempted tier
- :class:`StreamError` playback resolution failed for other reasons
- :class:`ManifestError` manifest missing, corrupt, or DRM-protected
- :class:`UnsupportedType` resource kind this library does not handle
- :class:`DownloadError` network/disk failure while fetching media bytes
- :class:`DiskFullError` the disk could not accept the media bytes
- :class:`CancelledError` the host cancelled the operation
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
[docs]
class TidalError(Exception):
"""Base class for every error raised by tidalpy.
Every error carries a stable machine ``code`` (e.g.
``"auth-expired"``) that hosts may key off without string-matching
the message, and a ``retryable`` flag: ``True`` when the same call
may succeed later without host action (transient network, rate
limit), ``False`` for permanent failures (bad token, region lock)
where retrying wastes time.
"""
code: str = "tidal-error"
retryable: bool = False
# Optional context (subclasses assign these unconditionally; the base
# class only sets them when provided, so ``to_dict`` uses getattr).
status_code: int | None
sub_status: int | str | None
[docs]
def __init__(
self,
message: str,
*details: Any,
status_code: int | None = None,
sub_status: int | str | None = None,
) -> None:
super().__init__(message)
self.message = message
self.details: list[Any] = list(details)
if status_code is not None:
self.status_code = status_code
if sub_status is not None:
self.sub_status = sub_status
[docs]
def to_dict(self) -> dict[str, Any]:
"""JSON-serialisable representation (safe for Django JSONField)."""
payload: dict[str, Any] = {
"error": type(self).__name__,
"code": self.code,
"message": str(self),
"retryable": self.retryable,
"timestamp": _utc_now_iso(),
}
status_code = getattr(self, "status_code", None)
sub_status = getattr(self, "sub_status", None)
if status_code is not None:
payload["status_code"] = status_code
if sub_status is not None:
payload["sub_status"] = sub_status
if self.details:
payload["details"] = [str(d) for d in self.details]
return payload
[docs]
class ConfigError(TidalError):
"""Configuration is missing or inconsistent."""
code = "config"
[docs]
class AuthError(TidalError):
"""Credentials are missing, expired, or unusable for this client."""
code = "auth"
[docs]
class RateLimitError(TidalError):
"""TIDAL returned 429 repeatedly; the wait budget is exhausted."""
code = "rate-limited"
retryable = True
[docs]
class NotFoundError(TidalError):
"""The resource id does not exist (v1 and, when applicable, v2)."""
code = "not-found"
[docs]
class RegionLockedError(TidalError):
"""The content is not available in the account's country."""
code = "region-locked"
[docs]
class PreviewOnlyError(TidalError):
"""Only a preview (e.g. free account) is offered for this content."""
code = "preview-only"
[docs]
class QualityUnavailableError(TidalError):
"""No stream could be resolved at any attempted quality tier."""
code = "quality-unavailable"
[docs]
class TidalApiError(TidalError):
"""A Tidal API rejection with HTTP/subStatus context attached."""
code = "api"
[docs]
def __init__(
self,
message: str,
status_code: int | None = None,
sub_status: int | str | None = None,
codes: list[str] | None = None,
*details: Any,
) -> None:
super().__init__(
message, *details, status_code=status_code, sub_status=sub_status
)
self.status_code = status_code
self.sub_status = sub_status
# Machine codes the error body carried (e.g.
# ``CLIENT_NOT_ENTITLED``): account-capability rejections are
# distinguishable from track-specific blocks.
self.codes: list[str] = list(codes or [])
[docs]
class StreamError(TidalError):
"""Playback info / stream resolution failed for other reasons."""
code = "stream"
[docs]
class ManifestError(StreamError):
"""A stream manifest is missing, corrupt, oversized, or DRM-protected."""
code = "manifest"
[docs]
class UnsupportedType(TidalError):
"""The resource kind is not handled (e.g. videos) or not requested."""
code = "unsupported-type"
[docs]
class DownloadError(TidalError):
"""Fetching the media bytes failed (network, truncation, disk)."""
code = "download"
retryable = True
[docs]
class DiskFullError(DownloadError):
"""The destination disk ran out of space mid-download."""
code = "disk-full"
retryable = False
[docs]
class CancelledError(TidalError):
"""The host cancelled the in-flight operation."""
code = "cancelled"