use serde::{Deserialize, Serialize};
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Deserialize, Serialize)]
#[non_exhaustive]
pub struct LastFmErrorResponse {
pub message: String,
pub error: u32,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum LastFmError {
#[error("api error (method: {method}, code: {error_code}): {message}")]
Api {
method: String,
message: String,
error_code: u32,
retryable: bool,
},
#[error("rate limit exceeded{}", retry_after.map(|d| format!(" (retry after {}ms)", d.as_millis())).unwrap_or_default())]
RateLimited {
retry_after: Option<Duration>,
},
#[error("network error: {0}")]
Network(#[from] reqwest::Error),
#[error("failed to parse response: {0}")]
Parse(#[from] serde_json::Error),
#[error("file operation failed: {0}")]
Io(#[from] std::io::Error),
#[error("csv operation failed: {0}")]
Csv(#[from] csv::Error),
#[error("missing required environment variable: {0}")]
MissingEnvVar(String),
#[error("configuration error: {0}")]
Config(String),
#[error("http error (status: {status})")]
Http {
status: u16,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
#[error("invalid url: {source}")]
Url {
#[source]
source: url::ParseError,
},
}
impl LastFmError {
#[must_use]
pub const fn is_retryable(&self) -> bool {
match self {
Self::Api { retryable, .. } => *retryable,
Self::RateLimited { .. } | Self::Network(_) => true,
_ => false,
}
}
#[must_use]
pub const fn retry_after(&self) -> Option<Duration> {
match self {
Self::RateLimited { retry_after } => *retry_after,
_ => None,
}
}
#[must_use]
pub fn api_method(&self) -> Option<&str> {
match self {
Self::Api { method, .. } => Some(method),
_ => None,
}
}
#[must_use]
pub const fn api_error_code(&self) -> Option<u32> {
match self {
Self::Api { error_code, .. } => Some(*error_code),
_ => None,
}
}
#[must_use]
pub fn api_message(&self) -> Option<&str> {
match self {
Self::Api { message, .. } => Some(message),
_ => None,
}
}
#[must_use]
pub fn env_var_name(&self) -> Option<&str> {
match self {
Self::MissingEnvVar(name) => Some(name),
_ => None,
}
}
#[must_use]
pub const fn http_status(&self) -> Option<u16> {
match self {
Self::Http { status, .. } => Some(*status),
_ => None,
}
}
}
impl From<url::ParseError> for LastFmError {
fn from(err: url::ParseError) -> Self {
Self::Url { source: err }
}
}
pub type Result<T> = std::result::Result<T, LastFmError>;
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_retryable_errors() {
let api_error = LastFmError::Api {
method: "test.method".to_string(),
message: "Temporary error".to_string(),
error_code: 500,
retryable: true,
};
assert!(api_error.is_retryable());
let non_retryable = LastFmError::Api {
method: "test.method".to_string(),
message: "Invalid API key".to_string(),
error_code: 10,
retryable: false,
};
assert!(!non_retryable.is_retryable());
let rate_limited = LastFmError::RateLimited {
retry_after: Some(Duration::from_secs(5)),
};
assert!(rate_limited.is_retryable());
let parse_error = LastFmError::Parse(serde_json::from_str::<()>("invalid").unwrap_err());
assert!(!parse_error.is_retryable());
}
#[test]
fn test_rate_limit_retry_after() {
let error = LastFmError::RateLimited {
retry_after: Some(Duration::from_secs(5)),
};
assert_eq!(error.retry_after(), Some(Duration::from_secs(5)));
let api_error = LastFmError::Api {
method: "test".to_string(),
message: "Error".to_string(),
error_code: 500,
retryable: true,
};
assert_eq!(api_error.retry_after(), None);
}
#[test]
fn test_error_display() {
let error = LastFmError::MissingEnvVar("LAST_FM_API_KEY".to_string());
let display = format!("{error}");
assert_eq!(
display,
"missing required environment variable: LAST_FM_API_KEY"
);
}
#[test]
fn test_api_error_accessors() {
let error = LastFmError::Api {
method: "user.getrecenttracks".to_string(),
message: "Invalid API key".to_string(),
error_code: 10,
retryable: false,
};
assert_eq!(error.api_method(), Some("user.getrecenttracks"));
assert_eq!(error.api_error_code(), Some(10));
assert_eq!(error.api_message(), Some("Invalid API key"));
let parse_error = LastFmError::Parse(serde_json::from_str::<()>("invalid").unwrap_err());
assert_eq!(parse_error.api_method(), None);
assert_eq!(parse_error.api_error_code(), None);
assert_eq!(parse_error.api_message(), None);
}
#[test]
fn test_env_var_accessor() {
let error = LastFmError::MissingEnvVar("LAST_FM_API_KEY".to_string());
assert_eq!(error.env_var_name(), Some("LAST_FM_API_KEY"));
let api_error = LastFmError::Api {
method: "test".to_string(),
message: "Error".to_string(),
error_code: 10,
retryable: false,
};
assert_eq!(api_error.env_var_name(), None);
}
#[test]
fn test_http_error() {
let error = LastFmError::Http {
status: 404,
source: None,
};
assert_eq!(error.http_status(), Some(404));
assert_eq!(format!("{error}"), "http error (status: 404)");
}
#[test]
fn test_display_messages_format() {
assert_eq!(
format!(
"{}",
LastFmError::Api {
method: "user.getrecenttracks".to_string(),
message: "Invalid API key".to_string(),
error_code: 10,
retryable: false
}
),
"api error (method: user.getrecenttracks, code: 10): Invalid API key"
);
assert_eq!(
format!("{}", LastFmError::RateLimited { retry_after: None }),
"rate limit exceeded"
);
assert_eq!(
format!(
"{}",
LastFmError::RateLimited {
retry_after: Some(Duration::from_secs(5))
}
),
"rate limit exceeded (retry after 5000ms)"
);
assert_eq!(
format!("{}", LastFmError::MissingEnvVar("TEST".to_string())),
"missing required environment variable: TEST"
);
assert_eq!(
format!("{}", LastFmError::Config("bad value".to_string())),
"configuration error: bad value"
);
}
}