use std::sync::OnceLock;
use std::time::Duration;
static MEDIA_HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
#[must_use]
pub fn bearer_auth_header() -> String {
format!(
"Bearer {}",
crate::config::CONFIG.provider_key().unwrap_or_default()
)
}
#[must_use]
pub(crate) fn extract_http_status(msg: &str) -> Option<u16> {
msg.split(|c: char| !c.is_ascii_digit())
.filter_map(|w| w.parse::<u16>().ok())
.find(|&code| (400..500).contains(&code))
}
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 = response.text().await.unwrap_or_default();
let preview = crate::util::truncate(&error_text, 500);
anyhow::bail!("{error_context} API error ({status}): {preview}");
}
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(),
)
})
}
pub async fn post_json_to_provider(
url: &str,
body: &serde_json::Value,
error_context: &str,
) -> anyhow::Result<serde_json::Value> {
let auth = bearer_auth_header();
let client = media_http_client();
let response = client
.post(url)
.header("Authorization", &auth)
.json(body)
.send()
.await
.map_err(|e| anyhow::anyhow!("{error_context} request failed: {e}"))?;
let response = check_response(response, error_context).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 auth = bearer_auth_header();
let client = media_http_client();
let response = client
.get(url)
.header("Authorization", &auth)
.send()
.await
.map_err(|e| anyhow::anyhow!("{error_context} request failed: {e}"))?;
let response = check_response(response, error_context).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 auth = bearer_auth_header();
let client = media_http_client();
let response = client
.get(url)
.header("Authorization", &auth)
.send()
.await
.map_err(|e| anyhow::anyhow!("{error_context} request failed: {e}"))?;
let response = check_response(response, error_context).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()
.unwrap_or_else(|error| {
tracing::warn!(
"Failed to build custom HTTP client: {error}; falling back to Client::new()"
);
reqwest::Client::new()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_http_status_detects_4xx() {
let cases: Vec<(&str, Option<u16>, &str)> = vec![
(
"API error (402): Insufficient credits",
Some(402),
"402 payment required — primary use case",
),
(
"Video generation submission API error (402):",
Some(402),
"402 in video_gen format",
),
(
"OpenAI API error (400): Bad Request",
Some(400),
"400 bad request",
),
(
"API error (401): Unauthorized",
Some(401),
"401 unauthorized",
),
("API error (403): Forbidden", Some(403), "403 forbidden"),
("API error (404): Not Found", Some(404), "404 not found"),
(
"API error (408): Request Timeout",
Some(408),
"408 request timeout",
),
(
"API error (429): Too Many Requests",
Some(429),
"429 too many requests",
),
(
"500 Server Error",
None,
"5xx ignored — not in 400-500 range",
),
("502 Bad Gateway", None, "5xx ignored"),
("200 OK", None, "2xx ignored"),
("connection reset", None, "no status code at all"),
("", None, "empty string"),
(
"field value is 400 but should be rejected",
Some(400),
"number in body text within range — accepted false positive",
),
("error code 1113", None, "four-digit number outside range"),
(
"HTTP 402 Payment Required",
Some(402),
"bare status in message without parens",
),
("Status 429", Some(429), "bare two-digit-then-three-digit"),
];
for (msg, expected, description) in cases {
let result = extract_http_status(msg);
assert_eq!(
result, expected,
"extract_http_status({msg:?}): expected {expected:?}, got {result:?} — {description}",
);
}
}
#[test]
fn extract_http_status_handles_adjacent_text() {
assert_eq!(
extract_http_status("API error (402)"),
Some(402),
"parenthesised status"
);
assert_eq!(
extract_http_status("code402"),
Some(402),
"digits adjacent to text without delimiter"
);
assert_eq!(
extract_http_status("402error"),
Some(402),
"digits followed by text"
);
assert_eq!(
extract_http_status("error402error"),
Some(402),
"digits surrounded by text"
);
assert_eq!(
extract_http_status("value_is_400"),
Some(400),
"400 as part of identifier — accepted false positive"
);
}
}