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