Skip to main content

shelly_core/
error.rs

1use thiserror::Error as ThisError;
2
3/// Typed error for every `shelly-core` transport path.
4///
5/// The five variants map 1:1 onto `switchkit::Error`: a caller that already
6/// speaks that vocabulary can translate this enum without guesswork.
7///
8/// `#[non_exhaustive]` because the error taxonomy is expected to grow; this
9/// is free to add now and a breaking change to add later, for a crate about
10/// to publish its first crates.io release.
11#[derive(Debug, ThisError)]
12#[non_exhaustive]
13pub enum Error {
14    /// The device could not be reached at all (connect/timeout/read failure),
15    /// or it responded with a non-success HTTP status that is not an auth
16    /// failure.
17    #[error("network error: {message}")]
18    Network { message: String },
19
20    /// The device rejected the request because of missing or invalid
21    /// credentials (HTTP 401/403).
22    #[error("authentication error: {message}")]
23    Auth { message: String },
24
25    /// The device was reached, answered with a well-formed response, but
26    /// explicitly rejected the request (a Gen2 RPC `error` object in an
27    /// HTTP 200 body).
28    #[error("request rejected: {message}")]
29    Rejected { message: String },
30
31    /// The device was reached but its response could not be interpreted:
32    /// invalid JSON, or a `/shelly` body that does not describe a Shelly
33    /// device. Reachable-but-not-Shelly is deliberately `Parse`, not
34    /// `Network`, so callers can distinguish "nothing there" from "something
35    /// else is there".
36    #[error("failed to parse response: {message}")]
37    Parse { message: String },
38
39    /// The operation is genuinely not supported, either by this device
40    /// generation or by this client (replaces the old `anyhow::bail!("not
41    /// supported")` paths).
42    #[error("unsupported operation: {message}")]
43    Unsupported { message: String },
44}
45
46pub type Result<T> = std::result::Result<T, Error>;
47
48/// Classify a raw `reqwest::Error` into a typed `Error`.
49///
50/// `reqwest::Error` already distinguishes response-decode failures
51/// (`is_decode`) from connect/timeout/request-level failures, so a plain `?`
52/// on `send()`/`json()` calls routes through this impl and lands on the
53/// right variant without every call site repeating the classification.
54impl From<reqwest::Error> for Error {
55    fn from(err: reqwest::Error) -> Self {
56        if err.is_decode() {
57            Error::Parse {
58                message: scrub(&err.to_string()),
59            }
60        } else {
61            Error::Network {
62                message: scrub(&err.to_string()),
63            }
64        }
65    }
66}
67
68/// Classify a non-success HTTP response into `Auth` (401/403) or `Network`
69/// (everything else). An HTTP 200 with an RPC-error body is handled
70/// separately by the Gen2 RPC caller; this only covers the status line.
71pub(crate) fn status_error(status: reqwest::StatusCode, url: &str, body: &str) -> Error {
72    let message = scrub(&format!("HTTP {status} from {url}: {body}"));
73    if status.as_u16() == 401 || status.as_u16() == 403 {
74        Error::Auth { message }
75    } else {
76        Error::Network { message }
77    }
78}
79
80/// Scrub potentially sensitive data from a raw error/diagnostic string
81/// before it becomes part of a typed `Error`.
82///
83/// Shelly authentication is sent as a Basic-auth HTTP header, never embedded
84/// in a URL, so the leak surface here is small today. This still strips a
85/// `user:pass@` userinfo component defensively (e.g. if a future caller ever
86/// builds a URL that way), and exists as a single choke point so every
87/// transport path scrubs uniformly instead of ad hoc per call site.
88pub(crate) fn scrub(raw: &str) -> String {
89    match (raw.find("://"), raw.find('@')) {
90        (Some(scheme_end), Some(at)) if at > scheme_end + 3 => {
91            let mut out = String::with_capacity(raw.len());
92            out.push_str(&raw[..scheme_end + 3]);
93            out.push_str(&raw[at + 1..]);
94            out
95        }
96        _ => raw.to_string(),
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn scrub_strips_userinfo_from_url() {
106        let raw = "error sending request for url (http://admin:secret@192.0.2.1/status)";
107        let scrubbed = scrub(raw);
108        assert!(!scrubbed.contains("secret"));
109        assert!(scrubbed.contains("192.0.2.1/status"));
110    }
111
112    #[test]
113    fn scrub_leaves_plain_messages_untouched() {
114        let raw = "connection refused";
115        assert_eq!(scrub(raw), raw);
116    }
117
118    #[test]
119    fn status_error_401_is_auth() {
120        let err = status_error(
121            reqwest::StatusCode::UNAUTHORIZED,
122            "http://192.0.2.1/status",
123            "",
124        );
125        assert!(matches!(err, Error::Auth { .. }));
126    }
127
128    #[test]
129    fn status_error_403_is_auth() {
130        let err = status_error(
131            reqwest::StatusCode::FORBIDDEN,
132            "http://192.0.2.1/status",
133            "",
134        );
135        assert!(matches!(err, Error::Auth { .. }));
136    }
137
138    #[test]
139    fn status_error_500_is_network() {
140        let err = status_error(
141            reqwest::StatusCode::INTERNAL_SERVER_ERROR,
142            "http://192.0.2.1/status",
143            "",
144        );
145        assert!(matches!(err, Error::Network { .. }));
146    }
147}