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, Session 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    /// Cloud console is offline or unreachable through the connector.
54    #[error("Cloud console offline or unreachable: {host_id}")]
55    ConsoleOffline { host_id: String },
56
57    /// API key cannot access the requested cloud console.
58    #[error("Not authorized to access cloud console: {host_id}")]
59    ConsoleAccessDenied { host_id: String },
60
61    // ── Integration API ─────────────────────────────────────────────
62    /// Structured error from the Integration API.
63    #[error("Integration API error (HTTP {status}): {message}")]
64    Integration {
65        message: String,
66        code: Option<String>,
67        status: u16,
68    },
69
70    // ── Session API ──────────────────────────────────────────────────
71    /// Error from the session API (parsed from the `{meta: {rc, msg}}` envelope).
72    #[error("Session API error: {message}")]
73    SessionApi { message: String },
74
75    // ── WebSocket ───────────────────────────────────────────────────
76    /// WebSocket connection failed.
77    #[error("WebSocket connection failed: {0}")]
78    WebSocketConnect(String),
79
80    /// WebSocket closed unexpectedly.
81    #[error("WebSocket closed (code {code}): {reason}")]
82    WebSocketClosed { code: u16, reason: String },
83
84    // ── Data ────────────────────────────────────────────────────────
85    /// JSON deserialization failed, with the raw body for debugging.
86    #[error("Deserialization error: {message}")]
87    Deserialization { message: String, body: String },
88
89    // ── Platform ────────────────────────────────────────────────────
90    /// Operation not supported on this controller platform.
91    #[error("Unsupported operation: {0}")]
92    UnsupportedOperation(&'static str),
93}
94
95impl Error {
96    /// Returns `true` if this error indicates auth has expired
97    /// and re-authentication might resolve it.
98    pub fn is_auth_expired(&self) -> bool {
99        matches!(self, Self::Authentication { .. } | Self::SessionExpired)
100    }
101
102    /// Returns `true` if this is a transient error worth retrying.
103    pub fn is_transient(&self) -> bool {
104        match self {
105            Self::Transport(e) => e.is_timeout() || e.is_connect(),
106            Self::Timeout { .. }
107            | Self::RateLimited { .. }
108            | Self::ConsoleOffline { .. }
109            | Self::WebSocketConnect(_) => true,
110            _ => false,
111        }
112    }
113
114    /// Returns `true` if this is a "not found" error.
115    pub fn is_not_found(&self) -> bool {
116        match self {
117            Self::Transport(e) => e.status() == Some(reqwest::StatusCode::NOT_FOUND),
118            Self::Integration { status: 404, .. } => true,
119            Self::SessionApi { message } => {
120                message.starts_with("HTTP 404") || message.contains("api.err.NotFound")
121            }
122            _ => false,
123        }
124    }
125
126    /// Extract the API error code, if available.
127    pub fn api_error_code(&self) -> Option<&str> {
128        match self {
129            Self::Integration { code, .. } => code.as_deref(),
130            _ => None,
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::Error;
138
139    #[test]
140    fn session_api_http_404_counts_as_not_found() {
141        let error = Error::SessionApi {
142            message: "HTTP 404 Not Found: {\"meta\":{\"rc\":\"error\",\"msg\":\"api.err.NotFound\"},\"data\":[]}".into(),
143        };
144
145        assert!(error.is_not_found());
146    }
147
148    #[test]
149    fn session_api_not_found_code_counts_as_not_found() {
150        let error = Error::SessionApi {
151            message: "api.err.NotFound".into(),
152        };
153
154        assert!(error.is_not_found());
155    }
156}