Skip to main content

wavekat_platform_client/
error.rs

1//! Public error type for the crate.
2//!
3//! Library convention: typed variants so consumers can `match` on the
4//! failure mode (network vs. HTTP status vs. OAuth state mismatch).
5//! End-user binaries can `?` these into their own `anyhow::Result`
6//! without losing information.
7
8use std::time::Duration;
9
10/// All errors surfaced by the crate.
11#[derive(Debug, thiserror::Error)]
12pub enum Error {
13    /// The platform returned 401. Split out from [`Error::Http`] so
14    /// consumers can render a tailored "sign in again" message instead
15    /// of the raw response body — the right remedy is almost always
16    /// "mint a fresh token", and the body alone (`{"error":"unauthenticated"}`)
17    /// doesn't tell the user that.
18    #[error("HTTP 401 {url}: {body}")]
19    Unauthorized { url: String, body: String },
20
21    /// The platform returned 401 `reauth_required`: the credential is
22    /// valid but was not minted recently enough for a destructive
23    /// action. Split out from [`Error::Unauthorized`] because the
24    /// remedy is different — the caller signs in again and retries,
25    /// rather than treating the session as dead and dropping the token.
26    #[error("re-authentication required: {url}")]
27    ReauthRequired { url: String },
28
29    /// The platform returned a non-2xx status (other than 401, which
30    /// splits into [`Error::Unauthorized`] and [`Error::ReauthRequired`]).
31    /// `body` is truncated to a reasonable size before being attached.
32    #[error("HTTP {status} {url}: {body}")]
33    Http {
34        status: u16,
35        url: String,
36        body: String,
37    },
38
39    /// Underlying transport failure (DNS, TLS, connection reset, …).
40    #[error("network error: {0}")]
41    Network(#[from] reqwest::Error),
42
43    /// The response body wasn't valid JSON for the expected shape.
44    #[error("decoding response from {url}: {source}")]
45    Decode {
46        url: String,
47        #[source]
48        source: serde_json::Error,
49    },
50
51    /// The OAuth callback returned a `state` value that didn't match
52    /// what we generated. Refusing the token is the only safe move.
53    #[error("OAuth state mismatch — got {actual:?}, expected {expected:?}")]
54    StateMismatch {
55        actual: Option<String>,
56        expected: String,
57    },
58
59    /// The user (or the platform) cancelled the OAuth flow in the
60    /// browser. The `String` carries the platform-supplied reason.
61    #[error("OAuth flow cancelled in browser: {0}")]
62    Cancelled(String),
63
64    /// The OAuth handshake didn't complete within the allotted time.
65    #[error("OAuth handshake timed out after {0:?}")]
66    Timeout(Duration),
67
68    /// Caller-side problem — usually a malformed input (e.g. a token
69    /// that contains bytes we can't put in an HTTP header).
70    #[error("bad request: {0}")]
71    BadRequest(String),
72
73    /// Local I/O failure (loopback bind, socket read/write, …).
74    #[error("I/O: {0}")]
75    Io(#[from] std::io::Error),
76}
77
78/// Split a 401 body into the two errors that need different remedies.
79///
80/// The platform answers `reauth_required` when the credential is fine
81/// but too old to authorise something irreversible — the caller signs
82/// in again and retries. Every other 401 means the credential itself is
83/// finished, and retrying with it is pointless.
84///
85/// Matched as a substring rather than parsed as JSON on purpose: a 401
86/// can also arrive from something in front of the API with an HTML
87/// body, and this must never fail closed into the wrong remedy just
88/// because the body didn't deserialize.
89pub(crate) fn classify_unauthorized(url: String, body: String) -> Error {
90    if body.contains("reauth_required") {
91        Error::ReauthRequired { url }
92    } else {
93        Error::Unauthorized { url, body }
94    }
95}
96
97pub type Result<T> = std::result::Result<T, Error>;
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn a_401_naming_reauth_is_classified_as_reauth_required() {
105        let err = classify_unauthorized(
106            "https://api.test/api/me".to_string(),
107            r#"{"error":"reauth_required"}"#.to_string(),
108        );
109        assert!(matches!(err, Error::ReauthRequired { .. }), "got {err:?}");
110    }
111
112    #[test]
113    fn a_plain_401_stays_unauthorized() {
114        let err = classify_unauthorized(
115            "https://api.test/api/me".to_string(),
116            r#"{"error":"unauthenticated"}"#.to_string(),
117        );
118        assert!(matches!(err, Error::Unauthorized { .. }), "got {err:?}");
119    }
120
121    #[test]
122    fn reauth_required_says_so_and_names_the_url() {
123        // The daemon logs this verbatim; "401" alone reads as a dead
124        // session, which is the wrong remedy.
125        let err = classify_unauthorized(
126            "https://api.test/api/me".to_string(),
127            r#"{"error":"reauth_required"}"#.to_string(),
128        );
129        let s = err.to_string();
130        assert!(s.contains("re-authentication"), "{s}");
131        assert!(s.contains("https://api.test/api/me"), "{s}");
132    }
133}