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}