use std::sync::OnceLock;
use std::time::Duration;
static MEDIA_HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
#[must_use]
pub fn bearer_auth_header() -> Option<String> {
let key = crate::config::CONFIG.provider_key()?;
if key.is_empty() {
return None;
}
Some(format!("Bearer {key}"))
}
pub(crate) async fn read_error_body(response: reqwest::Response, context: &str) -> String {
response.text().await.unwrap_or_else(|e| {
tracing::warn!(?e, "Failed to read {context} response body");
"failed to read response body".to_string()
})
}
async fn check_response(
response: reqwest::Response,
error_context: &str,
) -> anyhow::Result<reqwest::Response> {
let status = response.status();
if !status.is_success() {
let error_text = read_error_body(response, error_context).await;
let preview = crate::util::truncate(&error_text, 500);
return Err(anyhow::Error::from(super::error::HttpError::new(
status.as_u16(),
error_context,
preview,
None,
)));
}
Ok(response)
}
pub(crate) fn parse_json_response(
body_text: &str,
error_context: &str,
) -> anyhow::Result<serde_json::Value> {
serde_json::from_str(body_text).map_err(|e| {
anyhow::anyhow!(
"{error_context} response parse error: {e}\nraw response body ({}): {body_text:.500}",
body_text.len(),
)
})
}
async fn provider_request(
error_context: &str,
build_request: impl FnOnce(&reqwest::Client) -> reqwest::RequestBuilder,
) -> anyhow::Result<reqwest::Response> {
let auth = bearer_auth_header()
.ok_or_else(|| anyhow::anyhow!("{error_context}: provider API key is not configured"))?;
let client = media_http_client();
let response = build_request(client)
.header("Authorization", &auth)
.send()
.await
.map_err(|e| anyhow::anyhow!("{error_context} request failed: {e}"))?;
check_response(response, error_context).await
}
pub async fn post_json_to_provider(
url: &str,
body: &serde_json::Value,
error_context: &str,
) -> anyhow::Result<serde_json::Value> {
let response = provider_request(error_context, |client| client.post(url).json(body)).await?;
let body_text = response
.text()
.await
.map_err(|e| anyhow::anyhow!("{error_context} failed to read response body: {e}"))?;
parse_json_response(&body_text, error_context)
}
pub async fn get_json_from_provider(
url: &str,
error_context: &str,
) -> anyhow::Result<serde_json::Value> {
let response = provider_request(error_context, |client| client.get(url)).await?;
let body_text = response
.text()
.await
.map_err(|e| anyhow::anyhow!("{error_context} failed to read response body: {e}"))?;
parse_json_response(&body_text, error_context)
}
pub async fn get_bytes_from_provider(url: &str, error_context: &str) -> anyhow::Result<Vec<u8>> {
let response = provider_request(error_context, |client| client.get(url)).await?;
response
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| anyhow::anyhow!("{error_context} failed to read response body: {e}"))
}
#[must_use]
pub fn media_http_client() -> &'static reqwest::Client {
MEDIA_HTTP_CLIENT.get_or_init(|| build_http_client(Duration::from_mins(2)))
}
#[must_use]
pub fn build_http_client(timeout: Duration) -> reqwest::Client {
reqwest::Client::builder()
.timeout(timeout)
.connect_timeout(Duration::from_secs(10))
.build()
.expect("Failed to build HTTP client (TLS initialization failure)")
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn check_response_returns_http_error_on_non_2xx() {
let http_resp = http::Response::builder()
.status(402)
.body("Insufficient credits: please top up your account".to_string())
.unwrap();
let resp = reqwest::Response::from(http_resp);
let result = check_response(resp, "Video generation submission").await;
assert!(result.is_err(), "expected error for 402 status");
assert_eq!(
result
.unwrap_err()
.downcast_ref::<crate::util::error::HttpError>()
.map(|e| e.status),
Some(402),
);
}
#[tokio::test]
async fn check_response_truncates_long_body() {
let long_body = "x".repeat(1000);
let http_resp = http::Response::builder()
.status(400)
.body(long_body.clone())
.unwrap();
let resp = reqwest::Response::from(http_resp);
let result = check_response(resp, "test").await;
assert!(result.is_err());
let err = result.unwrap_err();
let http_err = err.downcast_ref::<crate::util::error::HttpError>().unwrap();
assert!(
http_err.body.len() <= 503,
"body should be truncated, got {} bytes",
http_err.body.len()
);
assert!(
http_err.body.len() < long_body.len(),
"truncated body ({}) should be shorter than original ({})",
http_err.body.len(),
long_body.len(),
);
assert!(
http_err.body.ends_with('…'),
"truncated body should end with ellipsis"
);
assert_eq!(http_err.status, 400);
}
#[tokio::test]
async fn check_response_returns_ok_on_2xx() {
let http_resp = http::Response::builder()
.status(200)
.body(r#"{"ok": true}"#.to_string())
.unwrap();
let resp = reqwest::Response::from(http_resp);
let result = check_response(resp, "test").await;
assert!(result.is_ok(), "expected success for 200 status");
}
}