Skip to main content

unifly_api/
error.rs

1use thiserror::Error;
2
3/// Top-level error type for the `unifly-api` crate.
4///
5/// Covers every failure mode across all API surfaces:
6/// authentication, transport, Integration API, Legacy API, WebSocket, and cloud.
7/// `unifly-core` maps these into user-facing diagnostics.
8#[derive(Debug, Error)]
9pub enum Error {
10    // ── Authentication ──────────────────────────────────────────────
11    /// Login failed (wrong credentials, account locked, etc.)
12    #[error("Authentication failed: {message}")]
13    Authentication { message: String },
14
15    /// 2FA token required but not provided.
16    #[error("Two-factor authentication token required")]
17    TwoFactorRequired,
18
19    /// Session has expired (cookie expired or revoked).
20    #[error("Session expired -- re-authentication required")]
21    SessionExpired,
22
23    /// Invalid API key (rejected by controller).
24    #[error("Invalid API key")]
25    InvalidApiKey,
26
27    /// Wrong credential type for the requested operation.
28    #[error("Wrong auth strategy: expected {expected}, got {got}")]
29    WrongAuthStrategy { expected: String, got: String },
30
31    // ── Transport ───────────────────────────────────────────────────
32    /// HTTP transport error (connection refused, DNS failure, etc.)
33    #[error("HTTP transport error: {0}")]
34    Transport(#[from] reqwest::Error),
35
36    /// URL parsing error.
37    #[error("Invalid URL: {0}")]
38    InvalidUrl(#[from] url::ParseError),
39
40    /// Request timed out.
41    #[error("Request timed out after {timeout_secs}s")]
42    Timeout { timeout_secs: u64 },
43
44    /// TLS handshake or certificate error.
45    #[error("TLS error: {0}")]
46    Tls(String),
47
48    // ── Cloud ───────────────────────────────────────────────────────
49    /// Rate limited by the cloud API. Includes retry-after in seconds.
50    #[error("Rate limited -- retry after {retry_after_secs}s")]
51    RateLimited { retry_after_secs: u64 },
52
53    // ── Integration API ─────────────────────────────────────────────
54    /// Structured error from the Integration API.
55    #[error("Integration API error (HTTP {status}): {message}")]
56    Integration {
57        message: String,
58        code: Option<String>,
59        status: u16,
60    },
61
62    // ── Legacy API ──────────────────────────────────────────────────
63    /// Error from the legacy API (parsed from the `{meta: {rc, msg}}` envelope).
64    #[error("Legacy API error: {message}")]
65    LegacyApi { message: String },
66
67    // ── WebSocket ───────────────────────────────────────────────────
68    /// WebSocket connection failed.
69    #[error("WebSocket connection failed: {0}")]
70    WebSocketConnect(String),
71
72    /// WebSocket closed unexpectedly.
73    #[error("WebSocket closed (code {code}): {reason}")]
74    WebSocketClosed { code: u16, reason: String },
75
76    // ── Data ────────────────────────────────────────────────────────
77    /// JSON deserialization failed, with the raw body for debugging.
78    #[error("Deserialization error: {message}")]
79    Deserialization { message: String, body: String },
80
81    // ── Platform ────────────────────────────────────────────────────
82    /// Operation not supported on this controller platform.
83    #[error("Unsupported operation: {0}")]
84    UnsupportedOperation(&'static str),
85}
86
87impl Error {
88    /// Returns `true` if this error indicates auth has expired
89    /// and re-authentication might resolve it.
90    pub fn is_auth_expired(&self) -> bool {
91        matches!(self, Self::Authentication { .. } | Self::SessionExpired)
92    }
93
94    /// Returns `true` if this is a transient error worth retrying.
95    pub fn is_transient(&self) -> bool {
96        match self {
97            Self::Transport(e) => e.is_timeout() || e.is_connect(),
98            Self::Timeout { .. } | Self::RateLimited { .. } | Self::WebSocketConnect(_) => true,
99            _ => false,
100        }
101    }
102
103    /// Returns `true` if this is a "not found" error.
104    pub fn is_not_found(&self) -> bool {
105        match self {
106            Self::Transport(e) => e.status() == Some(reqwest::StatusCode::NOT_FOUND),
107            Self::Integration { status: 404, .. } => true,
108            _ => false,
109        }
110    }
111
112    /// Extract the API error code, if available.
113    pub fn api_error_code(&self) -> Option<&str> {
114        match self {
115            Self::Integration { code, .. } => code.as_deref(),
116            _ => None,
117        }
118    }
119}