use reqwest::Client;
use serde::Deserialize;
use thiserror::Error;
#[derive(Debug, Deserialize)]
struct SpotifyErrorResponse {
error: SpotifyError,
}
#[derive(Debug, Deserialize)]
struct SpotifyError {
#[allow(dead_code)]
status: u16,
message: String,
}
#[derive(Debug, Error)]
pub enum HttpError {
#[error("Network error: {0}")]
Network(#[from] reqwest::Error),
#[error("{message}")]
Api { status: u16, message: String },
#[error("Rate limited - retry after {retry_after_secs} seconds")]
RateLimited { retry_after_secs: u64 },
#[error("Token expired or invalid")]
Unauthorized,
#[error("Access denied")]
Forbidden,
#[error("Resource not found")]
NotFound,
}
impl HttpError {
pub async fn from_response(response: reqwest::Response) -> Self {
let status = response.status().as_u16();
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(1);
let body = response.text().await.unwrap_or_default();
let message = if let Ok(spotify_err) = serde_json::from_str::<SpotifyErrorResponse>(&body) {
spotify_err.error.message
} else if body.len() < 200 && !body.contains('<') {
body
} else {
match status {
400 => "Bad request".to_string(),
401 => "Unauthorized".to_string(),
403 => "Forbidden".to_string(),
404 => "Not found".to_string(),
429 => "Rate limited".to_string(),
500..=599 => "Spotify server error".to_string(),
_ => format!("HTTP error {}", status),
}
};
match status {
401 => HttpError::Unauthorized,
403 => HttpError::Forbidden,
404 => HttpError::NotFound,
429 => HttpError::RateLimited {
retry_after_secs: retry_after,
},
_ => HttpError::Api { status, message },
}
}
pub fn retry_after(&self) -> Option<u64> {
match self {
HttpError::RateLimited { retry_after_secs } => Some(*retry_after_secs),
_ => None,
}
}
pub fn status_code(&self) -> u16 {
match self {
HttpError::Network(_) => 503,
HttpError::Api { status, .. } => *status,
HttpError::RateLimited { .. } => 429,
HttpError::Unauthorized => 401,
HttpError::Forbidden => 403,
HttpError::NotFound => 404,
}
}
pub fn user_message(&self) -> &str {
match self {
HttpError::Network(_) => "Network error - check your connection",
HttpError::Api { message, .. } => message,
HttpError::RateLimited { .. } => "Too many requests - please wait a moment",
HttpError::Unauthorized => "Session expired - run: spotify-cli auth refresh",
HttpError::Forbidden => "You don't have permission for this action",
HttpError::NotFound => "Resource not found",
}
}
}
pub struct HttpClient {
client: Client,
}
impl HttpClient {
pub fn new() -> Self {
Self {
client: Client::new(),
}
}
pub fn inner(&self) -> &Client {
&self.client
}
}
impl Default for HttpClient {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn http_error_status_codes() {
assert_eq!(HttpError::Unauthorized.status_code(), 401);
assert_eq!(HttpError::Forbidden.status_code(), 403);
assert_eq!(HttpError::NotFound.status_code(), 404);
assert_eq!(
HttpError::RateLimited {
retry_after_secs: 5
}
.status_code(),
429
);
assert_eq!(
HttpError::Api {
status: 500,
message: "Server error".to_string()
}
.status_code(),
500
);
}
#[test]
fn http_error_user_messages() {
assert_eq!(
HttpError::Unauthorized.user_message(),
"Session expired - run: spotify-cli auth refresh"
);
assert_eq!(
HttpError::Forbidden.user_message(),
"You don't have permission for this action"
);
assert_eq!(HttpError::NotFound.user_message(), "Resource not found");
assert_eq!(
HttpError::RateLimited {
retry_after_secs: 5
}
.user_message(),
"Too many requests - please wait a moment"
);
assert_eq!(
HttpError::Api {
status: 500,
message: "Custom error".to_string()
}
.user_message(),
"Custom error"
);
}
#[test]
fn http_error_retry_after() {
assert_eq!(
HttpError::RateLimited {
retry_after_secs: 30
}
.retry_after(),
Some(30)
);
assert_eq!(HttpError::Unauthorized.retry_after(), None);
assert_eq!(HttpError::NotFound.retry_after(), None);
}
#[test]
fn http_error_display() {
assert_eq!(
format!("{}", HttpError::Unauthorized),
"Token expired or invalid"
);
assert_eq!(format!("{}", HttpError::Forbidden), "Access denied");
assert_eq!(format!("{}", HttpError::NotFound), "Resource not found");
assert_eq!(
format!(
"{}",
HttpError::RateLimited {
retry_after_secs: 10
}
),
"Rate limited - retry after 10 seconds"
);
assert_eq!(
format!(
"{}",
HttpError::Api {
status: 400,
message: "Bad request".to_string()
}
),
"Bad request"
);
}
#[test]
fn http_client_default() {
let client = HttpClient::default();
let _ = client.inner();
}
#[test]
fn http_client_new() {
let client = HttpClient::new();
let _ = client.inner();
}
#[test]
fn http_error_api_various_statuses() {
let statuses = [400, 402, 405, 500, 502, 503];
for status in statuses {
let err = HttpError::Api {
status,
message: "test".to_string(),
};
assert_eq!(err.status_code(), status);
}
}
#[test]
fn http_error_is_debug() {
let err = HttpError::Unauthorized;
let debug = format!("{:?}", err);
assert!(debug.contains("Unauthorized"));
}
#[test]
fn http_error_api_user_message() {
let err = HttpError::Api {
status: 400,
message: "test msg".to_string(),
};
assert_eq!(err.user_message(), "test msg");
}
#[test]
fn http_error_display_for_all_variants() {
let api_err = HttpError::Api {
status: 500,
message: "Server error".to_string(),
};
assert_eq!(format!("{}", api_err), "Server error");
let rate_err = HttpError::RateLimited {
retry_after_secs: 30,
};
assert!(format!("{}", rate_err).contains("30"));
}
#[test]
fn spotify_error_response_deserialization() {
let json = r#"{"error": {"status": 400, "message": "Bad request"}}"#;
let err: SpotifyErrorResponse = serde_json::from_str(json).unwrap();
assert_eq!(err.error.message, "Bad request");
}
}