from __future__ import annotations from typing import Self import pydantic from fansifter_common.exceptions import FansifterError from fansifter_common.httpclient import HTTPClientError from resonance_engine.dsp.enums import DSPClientName class DSPError(FansifterError): """Base exception for DSP errors.""" code = "dsp_error" message = "DSP error" class DSPNotRegisteredError(DSPError): """Raised when a requested DSP is not registered in the gateway.""" def __init__(self, name: str) -> None: self.name = name super().__init__(f"DSP '{name}' is not registered") class UnknownDSPClientError(DSPError): """Raised when a request references a DSP client that doesn't exist.""" code = "unknown_dsp_client" message = "Unknown DSP client" status_code = 400 def __init__(self, dsp_client_name: DSPClientName) -> None: self.dsp_client_name = dsp_client_name super().__init__(f"DSP client {dsp_client_name} not found") class TokenRefreshError(DSPError): """Raised when an OAuth token refresh fails (transient — network, 5xx).""" class TokenRevokedError(TokenRefreshError): """Token permanently invalid (revoked/expired) — not retryable; fan marked stale.""" class StreamingAPIError(DSPError): """Raised when a DSP API call fails.""" def __init__( self, message: str, status_code: int = 500, retry_after: float | None = None, ) -> None: self.status_code = status_code self.retry_after = retry_after super().__init__(message) @classmethod def from_http_error(cls, exc: HTTPClientError) -> DSPError: # Single HTTP-status → DSP-exception mapping for every DSP. if exc.status_code == 401: return TokenRevokedError(str(exc)) retry_after: float | None = None if exc.response is not None: header = exc.response.headers.get("Retry-After") if header is not None: try: retry_after = float(header) except ValueError: pass error_cls = DSPForbiddenError if exc.status_code == 403 else cls return error_cls(str(exc), status_code=exc.status_code, retry_after=retry_after) class DSPForbiddenError(StreamingAPIError): """403 — token lacks permission for this resource; the collector skips it. `request_made` is False when skipped proactively (no HTTP), True on a real 403.""" def __init__( self, message: str, status_code: int = 403, retry_after: float | None = None, request_made: bool = True, ) -> None: self.request_made = request_made super().__init__(message, status_code=status_code, retry_after=retry_after) class DSPResourceUnsupportedError(DSPError): """The DSP has no endpoint for this resource — the backend raises it and the collector stamps the resource so it isn't re-attempted.""" class InvalidResponseError(DSPError): """A DSP response body failed schema validation (not retryable).""" code = "invalid_dsp_response" message = "Invalid DSP response" @classmethod def from_validation_error(cls, exc: pydantic.ValidationError) -> Self: parts = [ f"{'.'.join(str(p) for p in err['loc']) or '(root)'}: {err['msg']}" for err in exc.errors() ] return cls(message=f"{exc.title}: {'; '.join(parts)}") class DSPWaitTimeoutError(DSPError): """Raised when waiting for a paused DSP to resume exceeds the deadline.""" def __init__(self, name: str) -> None: self.name = name super().__init__(f"Timed out waiting for DSP '{name}' to resume") class RateLimitError(StreamingAPIError): """Severe throttling (Retry-After over threshold or retries exhausted) — the caller should abort the batch rather than skip individual fans."""