use thiserror::Error;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum Error {
#[error("Rate limited. Retry after {retry_after} seconds")]
RateLimited {
retry_after: f64,
message: String,
},
#[error("Not found: {message}")]
NotFound {
message: String,
},
#[error("Forbidden: {message}")]
Forbidden {
message: String,
},
#[error("HTTP error {status}: {message}")]
Http {
status: u16,
message: String,
},
#[error("Network error: {0}")]
Network(String),
#[error("Failed to parse response: {0}")]
Parse(#[from] serde_json::Error),
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Invalid Discord bot ID format: {0}. Expected 17-19 digit snowflake.")]
InvalidBotId(String),
#[error("Invalid Discord user ID format: {0}. Expected 17-19 digit snowflake.")]
InvalidUserId(String),
#[error("Missing or invalid API token")]
InvalidToken,
#[cfg(feature = "reqwest-client")]
#[cfg_attr(docsrs, doc(cfg(feature = "reqwest-client")))]
#[error("Request error: {0}")]
Reqwest(#[from] reqwest::Error),
#[cfg(feature = "ureq-client")]
#[cfg_attr(docsrs, doc(cfg(feature = "ureq-client")))]
#[error("Request error: {0}")]
Ureq(#[from] Box<ureq::Error>),
}
impl Error {
#[must_use]
pub const fn is_rate_limited(&self) -> bool {
matches!(self, Self::RateLimited { .. })
}
#[must_use]
pub const fn is_not_found(&self) -> bool {
matches!(self, Self::NotFound { .. })
}
#[must_use]
pub const fn retry_after(&self) -> Option<f64> {
match self {
Self::RateLimited { retry_after, .. } => Some(*retry_after),
_ => None,
}
}
}
#[derive(Debug, serde::Deserialize)]
pub(crate) struct ApiErrorResponse {
pub code: u16,
pub message: String,
#[serde(rename = "expiresIn")]
pub expires_in: Option<f64>,
}
impl From<ApiErrorResponse> for Error {
fn from(response: ApiErrorResponse) -> Self {
match response.code {
429 => Self::RateLimited {
retry_after: response.expires_in.unwrap_or(0.0),
message: response.message,
},
404 => Self::NotFound {
message: response.message,
},
403 => Self::Forbidden {
message: response.message,
},
status => Self::Http {
status,
message: response.message,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rate_limit_error() {
let err = Error::RateLimited {
retry_after: 26.0,
message: "You are being rate limited".to_string(),
};
assert!(err.is_rate_limited());
assert_eq!(err.retry_after(), Some(26.0));
assert!(err.to_string().contains("26"));
}
#[test]
fn test_not_found_error() {
let err = Error::NotFound {
message: "Bot not found".to_string(),
};
assert!(err.is_not_found());
assert!(!err.is_rate_limited());
assert_eq!(err.retry_after(), None);
}
#[test]
fn test_api_error_response_parsing() {
let json = r#"{"code": 429, "message": "Rate limited", "expiresIn": 30}"#;
let response: ApiErrorResponse = serde_json::from_str(json).unwrap();
let err: Error = response.into();
assert!(err.is_rate_limited());
assert_eq!(err.retry_after(), Some(30.0));
}
#[test]
fn test_api_error_404() {
let json = r#"{"code": 404, "message": "Not found"}"#;
let response: ApiErrorResponse = serde_json::from_str(json).unwrap();
let err: Error = response.into();
assert!(err.is_not_found());
}
#[test]
fn test_invalid_bot_id_error() {
let err = Error::InvalidBotId("abc".to_string());
assert!(err.to_string().contains("abc"));
assert!(err.to_string().contains("17-19 digit"));
}
}