1use std::path::PathBuf;
2use std::time::Duration;
3
4use reqwest::StatusCode;
5use reqwest::header::{HeaderMap, RETRY_AFTER};
6
7use crate::config::settings::Settings;
8use crate::error::{Result, YtError};
9
10pub struct HttpConfig {
11 pub timeout: Duration,
12 pub retries: u32,
13 pub ca_file: Option<PathBuf>,
14}
15
16impl HttpConfig {
17 pub fn from_settings(settings: &Settings, ca_file: Option<PathBuf>) -> Result<Self> {
18 Ok(Self {
19 timeout: settings.http_timeout()?,
20 retries: u32::try_from(settings.http_retries)
21 .unwrap_or(u32::MAX)
22 .min(10),
23 ca_file,
24 })
25 }
26}
27
28pub fn client(config: &HttpConfig) -> Result<reqwest::Client> {
29 build(builder(config)?)
30}
31
32pub fn client_without_redirects(config: &HttpConfig) -> Result<reqwest::Client> {
34 build(builder(config)?.redirect(reqwest::redirect::Policy::none()))
35}
36
37pub fn user_agent() -> String {
38 format!(
39 "yt/{} ({}-{})",
40 env!("CARGO_PKG_VERSION"),
41 std::env::consts::OS,
42 std::env::consts::ARCH
43 )
44}
45
46pub fn is_transient(status: StatusCode) -> bool {
47 matches!(status.as_u16(), 408 | 429 | 500..=599)
48}
49
50pub fn retry_delay(attempt: u32, headers: Option<&HeaderMap>) -> Duration {
51 let retry_after = headers
52 .and_then(|headers| headers.get(RETRY_AFTER))
53 .and_then(|value| value.to_str().ok())
54 .and_then(|value| value.trim().parse::<u64>().ok())
55 .map(Duration::from_secs);
56 match retry_after {
57 Some(delay) => delay.min(Duration::from_secs(15)),
58 None => match attempt {
59 0 => Duration::from_millis(200),
60 1 => Duration::from_secs(1),
61 _ => Duration::from_secs(4),
62 },
63 }
64}
65
66fn builder(config: &HttpConfig) -> Result<reqwest::ClientBuilder> {
67 let _ = rustls::crypto::ring::default_provider().install_default();
69 let mut builder = reqwest::Client::builder()
70 .user_agent(user_agent())
71 .connect_timeout(Duration::from_secs(10))
72 .gzip(true);
73 if !config.timeout.is_zero() {
74 builder = builder.timeout(config.timeout);
75 }
76 if let Some(path) = &config.ca_file {
77 let invalid = |message: String| YtError::Config {
78 path: path.display().to_string(),
79 message,
80 };
81 let pem = std::fs::read(path).map_err(|error| invalid(error.to_string()))?;
82 let certificates = reqwest::Certificate::from_pem_bundle(&pem)
83 .map_err(|error| invalid(format!("not a PEM certificate bundle: {error}")))?;
84 builder = builder.tls_certs_merge(certificates);
85 }
86 Ok(builder)
87}
88
89fn build(builder: reqwest::ClientBuilder) -> Result<reqwest::Client> {
90 builder
91 .build()
92 .map_err(|error| YtError::Network(error.to_string()).into())
93}
94
95#[cfg(test)]
96mod tests {
97 use reqwest::header::HeaderValue;
98
99 use super::*;
100
101 fn config() -> HttpConfig {
102 HttpConfig {
103 timeout: Duration::from_secs(5),
104 retries: 3,
105 ca_file: None,
106 }
107 }
108
109 #[test]
110 fn user_agent_names_the_version_and_platform() {
111 let agent = user_agent();
112 assert!(
113 agent.starts_with(&format!("yt/{} (", env!("CARGO_PKG_VERSION"))),
114 "{agent}"
115 );
116 assert!(agent.contains(std::env::consts::OS), "{agent}");
117 }
118
119 #[test]
120 fn transient_statuses() {
121 for status in [408, 429, 500, 502, 503, 504] {
122 assert!(
123 is_transient(StatusCode::from_u16(status).unwrap()),
124 "{status}"
125 );
126 }
127 for status in [400, 401, 403, 404, 409] {
128 assert!(
129 !is_transient(StatusCode::from_u16(status).unwrap()),
130 "{status}"
131 );
132 }
133 }
134
135 #[test]
136 fn retry_delays_back_off_and_honour_retry_after() {
137 assert_eq!(retry_delay(0, None), Duration::from_millis(200));
138 assert_eq!(retry_delay(1, None), Duration::from_secs(1));
139 assert_eq!(retry_delay(5, None), Duration::from_secs(4));
140 let mut headers = HeaderMap::new();
141 headers.insert(RETRY_AFTER, HeaderValue::from_static("2"));
142 assert_eq!(retry_delay(0, Some(&headers)), Duration::from_secs(2));
143 headers.insert(RETRY_AFTER, HeaderValue::from_static("120"));
144 assert_eq!(retry_delay(0, Some(&headers)), Duration::from_secs(15));
145 headers.insert(
146 RETRY_AFTER,
147 HeaderValue::from_static("Wed, 21 Oct 2026 07:28:00 GMT"),
148 );
149 assert_eq!(retry_delay(1, Some(&headers)), Duration::from_secs(1));
150 }
151
152 #[test]
153 fn clients_build_without_an_installed_provider() {
154 client(&config()).unwrap();
155 client_without_redirects(&config()).unwrap();
156 }
157
158 #[test]
159 fn a_missing_ca_file_is_a_config_error() {
160 let error = client(&HttpConfig {
161 ca_file: Some("/nonexistent/ca.pem".into()),
162 ..config()
163 })
164 .unwrap_err();
165 assert!(matches!(
166 error.downcast_ref::<YtError>(),
167 Some(YtError::Config { .. })
168 ));
169 }
170
171 #[test]
172 fn settings_map_to_config_and_retries_are_capped() {
173 let settings = Settings {
174 http_timeout: "5s".into(),
175 http_retries: 99,
176 color: "auto".into(),
177 cache_ttl: "1h".into(),
178 };
179 let config = HttpConfig::from_settings(&settings, None).unwrap();
180 assert_eq!(config.timeout, Duration::from_secs(5));
181 assert_eq!(config.retries, 10);
182 }
183}