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}