unifi/error.rs
1//! Typed errors for the UniFi client.
2//!
3//! Every fallible public function in this crate returns [`Result`], never a
4//! boxed/opaque error. Callers that need to react differently to "the API
5//! key was rejected" versus "the controller is unreachable" can match on
6//! [`UnifiError`] instead of parsing a message string.
7
8use std::time::Duration;
9
10use thiserror::Error;
11
12/// Result alias for this crate's fallible operations.
13pub type Result<T> = std::result::Result<T, UnifiError>;
14
15/// Everything that can go wrong talking to a UniFi controller.
16///
17/// Marked `#[non_exhaustive]`: this crate is meant to be published and
18/// consumed externally, so a caller that matches on this must include a
19/// wildcard arm — adding a new variant (e.g. a future status-class split)
20/// must never be a semver-breaking change for downstream code.
21#[derive(Debug, Error)]
22#[non_exhaustive]
23pub enum UnifiError {
24 /// `UnifiConfig::url` was empty.
25 #[error(
26 "UNIFI_URL is not set - set it to your controller's base URL, e.g. UNIFI_URL=https://unifi.local"
27 )]
28 MissingUrl,
29
30 /// `UnifiConfig::api_key` was empty.
31 #[error("UNIFI_API_KEY is not set - generate an API key in UniFi Settings > API")]
32 MissingApiKey,
33
34 /// The underlying `reqwest::Client` failed to build.
35 #[error("failed to build HTTP client: {0}")]
36 ClientBuild(#[source] reqwest::Error),
37
38 /// The request exceeded the client timeout.
39 #[error(
40 "{method} {url} timed out - check UNIFI_SKIP_TLS_VERIFY=true for self-signed certs, or verify the controller is reachable"
41 )]
42 Timeout {
43 /// HTTP method of the request that timed out.
44 method: String,
45 /// Full request URL.
46 url: String,
47 /// Underlying transport error (kept for `Error::source()` chain-walking
48 /// even though the message above doesn't repeat its text).
49 #[source]
50 source: reqwest::Error,
51 },
52
53 /// The controller could not be reached (DNS, TCP, or TLS handshake failure).
54 #[error(
55 "UniFi controller at {url} unreachable ({method}) - check UNIFI_URL is correct and the controller is running. For self-signed certs set UNIFI_SKIP_TLS_VERIFY=true"
56 )]
57 Connect {
58 /// HTTP method of the request that failed to connect.
59 method: String,
60 /// Full request URL.
61 url: String,
62 /// Underlying transport error (kept for `Error::source()` chain-walking
63 /// even though the message above doesn't repeat its text).
64 #[source]
65 source: reqwest::Error,
66 },
67
68 /// A transport-level failure other than timeout or connect.
69 #[error("{method} {url} failed: {source}")]
70 Request {
71 /// HTTP method of the failed request.
72 method: String,
73 /// Full request URL.
74 url: String,
75 /// Underlying transport error.
76 #[source]
77 source: reqwest::Error,
78 },
79
80 /// The controller rejected the API key (HTTP 401). Unlike the other
81 /// status-class variants this has no `method` field: a rejected key is
82 /// rejected the same way for every verb, so there's nothing extra to
83 /// carry — not an oversight.
84 #[error(
85 "UNIFI_API_KEY rejected by {0} (HTTP 401) - generate a new API key in UniFi Settings > API"
86 )]
87 Unauthorized(
88 /// Full request URL.
89 String,
90 ),
91
92 /// The API key is valid but lacks permission for the request (HTTP 403).
93 #[error("UniFi API key lacks permission for {method} {url} (HTTP 403)")]
94 Forbidden {
95 /// HTTP method of the forbidden request.
96 method: String,
97 /// Full request URL.
98 url: String,
99 },
100
101 /// The controller has no such endpoint (HTTP 404).
102 #[error("UniFi endpoint not found for {method} {url} (HTTP 404)")]
103 NotFound {
104 /// HTTP method of the request.
105 method: String,
106 /// Full request URL.
107 url: String,
108 },
109
110 /// The controller rejected the request for exceeding its rate limit (HTTP 429).
111 #[error(
112 "UniFi rate limit exceeded for {method} {url} (HTTP 429){}",
113 retry_after.map(|d| format!(" - retry after {}s", d.as_secs())).unwrap_or_default()
114 )]
115 RateLimited {
116 /// HTTP method of the rate-limited request.
117 method: String,
118 /// Full request URL.
119 url: String,
120 /// Parsed `Retry-After` response header, when present and expressed
121 /// in seconds (the HTTP-date form is not parsed).
122 retry_after: Option<Duration>,
123 },
124
125 /// A `GET` returned a successful status with no body, where a JSON body was expected.
126 #[error("UniFi returned an empty body for {method} {url}")]
127 EmptyBody {
128 /// HTTP method of the request.
129 method: String,
130 /// Full request URL.
131 url: String,
132 },
133
134 /// The response body was not valid JSON.
135 #[error("failed to parse JSON response from {url}: {source}")]
136 Decode {
137 /// Full request URL.
138 url: String,
139 /// Underlying JSON parse error.
140 #[source]
141 source: serde_json::Error,
142 },
143
144 /// The controller returned a non-success status not covered by a more specific variant.
145 #[error("UniFi HTTP {status} from {url}: {body}")]
146 UnexpectedStatus {
147 /// HTTP status code.
148 status: u16,
149 /// Full request URL.
150 url: String,
151 /// Response body, if any was returned.
152 body: Box<serde_json::Value>,
153 },
154
155 /// [`crate::ActionDispatcher::execute`] was asked to run an action that has no
156 /// registered [`crate::capabilities::Capability`].
157 #[error("unknown UniFi action: {0}")]
158 UnknownAction(String),
159
160 /// A request's parameters were malformed (wrong type, wrong API family, unknown
161 /// hybrid target, etc). `context` is an action name or an HTTP method + path,
162 /// whichever the caller had on hand.
163 #[error("invalid request for {context}: {message}")]
164 InvalidRequest {
165 /// What was being executed when validation failed.
166 context: String,
167 /// What was wrong with the request.
168 message: String,
169 },
170
171 /// Path-template substitution failed (unmatched brace, missing parameter, wrong type).
172 #[error("path template error: {0}")]
173 PathTemplate(String),
174
175 /// The `*path` connector wildcard segment failed validation (traversal, encoded
176 /// separators, or outside the allowed API prefixes).
177 #[error("unsafe or unsupported connector path: {0}")]
178 ConnectorPath(String),
179
180 /// Hybrid action routing (official vs. internal) failed.
181 #[error("hybrid action routing error: {0}")]
182 HybridRouting(String),
183}