use std::time::Duration;
use {reqwest::Response, serde::de::DeserializeOwned, tokio::time::sleep};
use crate::{
api::{
http_client::HttpClient,
response::{parse_response, timestamp},
},
errors::QobuzApiError::{self, DownloadError, RateLimitError, ResourceNotFoundError},
signing::sign_request,
};
macro_rules! signed_request {
(
$(#[$meta:meta])*
$vis:vis fn $name:ident($client:ident, $base_url:ident, $endpoint:ident, $params:ident, $auth:ident) -> $body:tt
) => {
$(#[$meta])*
$vis async fn $name<T: DeserializeOwned>(
$client: &dyn HttpClient,
$base_url: &str,
$endpoint: &str,
$params: &mut Vec<(String, String)>,
$auth: &RequestAuth<'_>,
) -> Result<T, QobuzApiError> $body
};
}
const MAX_RETRIES: u32 = 3;
const BASE_BACKOFF_MS: u64 = 500;
pub struct RequestAuth<'a> {
pub app_id: &'a str,
pub app_secret: &'a str,
pub user_auth_token: &'a str,
}
fn append_signature(
params: &mut Vec<(String, String)>,
method: &str,
endpoint: &str,
auth: &RequestAuth<'_>,
) {
params.push(("app_id".to_string(), auth.app_id.to_string()));
params.push(("request_ts".to_string(), timestamp()));
let sig = sign_request(method, endpoint, params, auth.app_secret);
params.push(("request_sig".to_string(), sig));
}
async fn execute_post<T: DeserializeOwned>(
client: &dyn HttpClient,
base_url: &str,
endpoint: &str,
params: &[(String, String)],
) -> Result<T, QobuzApiError> {
let url = format!("{base_url}{endpoint}");
let param_refs: Vec<(&str, &str)> = params
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let response = client.post_form(&url, ¶m_refs).await?;
parse_response::<T>(response, endpoint).await
}
signed_request!(
pub fn signed_get(client, base_url, endpoint, params, auth) -> {
append_signature(params, "GET", endpoint, auth);
let url = build_url_with_params(base_url, endpoint, params);
let response = retry_with_backoff(client, &url, auth.user_auth_token).await?;
parse_response::<T>(response, endpoint).await
}
);
pub async fn post<T: DeserializeOwned>(
client: &dyn HttpClient,
base_url: &str,
endpoint: &str,
params: &mut Vec<(String, String)>,
app_id: &str,
user_auth_token: &str,
) -> Result<T, QobuzApiError> {
params.push(("app_id".to_string(), app_id.to_string()));
if !user_auth_token.is_empty() {
params.push(("user_auth_token".to_string(), user_auth_token.to_string()));
}
execute_post::<T>(client, base_url, endpoint, params).await
}
signed_request!(
pub fn signed_post(client, base_url, endpoint, params, auth) -> {
params.push((
"user_auth_token".to_string(),
auth.user_auth_token.to_string(),
));
append_signature(params, "POST", endpoint, auth);
execute_post::<T>(client, base_url, endpoint, params).await
}
);
pub async fn download_stream(
client: &dyn HttpClient,
url: &str,
token: &str,
range: Option<&str>,
) -> Result<Response, QobuzApiError> {
let response = client.get_with_auth(url, token, range).await?;
let status = response.status();
if status.is_success() || status.as_u16() == 206 {
Ok(response)
} else if status.as_u16() == 404 {
Err(ResourceNotFoundError {
resource_type: "file".to_string(),
resource_id: url.to_string(),
})
} else {
Err(DownloadError {
message: format!("HTTP {status}"),
})
}
}
pub async fn retry_with_backoff(
client: &dyn HttpClient,
url: &str,
user_auth_token: &str,
) -> Result<Response, QobuzApiError> {
let mut last_error: Option<QobuzApiError> = None;
for attempt in 0..=MAX_RETRIES {
let response = client.get_with_auth(url, user_auth_token, None).await?;
let status = response.status();
if status.as_u16() == 429 {
let delay = BASE_BACKOFF_MS.saturating_mul(2u64.pow(attempt));
sleep(Duration::from_millis(delay)).await;
last_error = Some(RateLimitError {
message: format!("Rate limited, retry {attempt}/{MAX_RETRIES}"),
});
continue;
}
return Ok(response);
}
Err(last_error.unwrap_or_else(|| RateLimitError {
message: "Rate limited: retries exhausted".to_string(),
}))
}
#[must_use]
pub fn build_url_with_params(
base_url: &str,
endpoint: &str,
params: &[(String, String)],
) -> String {
let mut url = format!("{base_url}{endpoint}");
if !params.is_empty() {
let query: String = params
.iter()
.map(|(k, v)| format!("{}={}", urlencoding(k), urlencoding(v)))
.collect::<Vec<_>>()
.join("&");
url.push('?');
url.push_str(&query);
}
url
}
fn urlencoding(s: &str) -> String {
s.chars()
.map(|c| match c {
' ' => "+".to_string(),
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
_ => format!("%{:02X}", u32::from(c)),
})
.collect()
}
#[cfg(test)]
mod tests {
use {
anyhow::{Result, anyhow, ensure},
reqwest::Response,
tokio::runtime::Runtime,
};
use crate::{
api::{
requests::retry_with_backoff,
service::QobuzApiService,
test_support::{SequentialMockServer, make_service},
},
errors::QobuzApiError,
};
fn rate_limit_response() -> (u16, String) {
(
429,
r#"{"status":"error","message":"rate limited"}"#.to_string(),
)
}
fn make_test_request(service: &QobuzApiService) -> Result<Response, QobuzApiError> {
let rt = Runtime::new()?;
let client = service.http_client();
rt.block_on(retry_with_backoff(
client,
&format!("{}/test", service.base_url()),
"token",
))
}
#[test]
fn rate_limit_retry_exhausts_retries() -> Result<()> {
let server = SequentialMockServer::start(vec![
rate_limit_response(),
rate_limit_response(),
rate_limit_response(),
rate_limit_response(),
])?;
let service = make_service(&server.base_url())?;
let result = make_test_request(&service);
let err = result.err().ok_or_else(|| anyhow!("expected error"))?;
ensure!(format!("{err}").contains("Rate limited"));
Ok(())
}
#[test]
fn rate_limit_retry_succeeds_after_backoff() -> Result<()> {
let server = SequentialMockServer::start(vec![
rate_limit_response(),
rate_limit_response(),
(
200,
r#"{"url":"https://example.com/file.flac"}"#.to_string(),
),
])?;
let service = make_service(&server.base_url())?;
let result = make_test_request(&service)?;
ensure!(result.status().is_success());
Ok(())
}
}