Skip to main content

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}