use std::time::Duration;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("HTTP 401 {url}: {body}")]
Unauthorized { url: String, body: String },
#[error("re-authentication required: {url}")]
ReauthRequired { url: String },
#[error("HTTP {status} {url}: {body}")]
Http {
status: u16,
url: String,
body: String,
},
#[error("network error: {0}")]
Network(#[from] reqwest::Error),
#[error("decoding response from {url}: {source}")]
Decode {
url: String,
#[source]
source: serde_json::Error,
},
#[error("OAuth state mismatch — got {actual:?}, expected {expected:?}")]
StateMismatch {
actual: Option<String>,
expected: String,
},
#[error("OAuth flow cancelled in browser: {0}")]
Cancelled(String),
#[error("OAuth handshake timed out after {0:?}")]
Timeout(Duration),
#[error("bad request: {0}")]
BadRequest(String),
#[error("I/O: {0}")]
Io(#[from] std::io::Error),
}
pub(crate) fn classify_unauthorized(url: String, body: String) -> Error {
if body.contains("reauth_required") {
Error::ReauthRequired { url }
} else {
Error::Unauthorized { url, body }
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_401_naming_reauth_is_classified_as_reauth_required() {
let err = classify_unauthorized(
"https://api.test/api/me".to_string(),
r#"{"error":"reauth_required"}"#.to_string(),
);
assert!(matches!(err, Error::ReauthRequired { .. }), "got {err:?}");
}
#[test]
fn a_plain_401_stays_unauthorized() {
let err = classify_unauthorized(
"https://api.test/api/me".to_string(),
r#"{"error":"unauthenticated"}"#.to_string(),
);
assert!(matches!(err, Error::Unauthorized { .. }), "got {err:?}");
}
#[test]
fn reauth_required_says_so_and_names_the_url() {
let err = classify_unauthorized(
"https://api.test/api/me".to_string(),
r#"{"error":"reauth_required"}"#.to_string(),
);
let s = err.to_string();
assert!(s.contains("re-authentication"), "{s}");
assert!(s.contains("https://api.test/api/me"), "{s}");
}
}