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}