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 describe(error: &reqwest::Error) -> String {
48 let message = match std::error::Error::source(error) {
49 Some(reason) if error.is_redirect() => format!("{error}: {reason}"),
50 _ => error.to_string(),
51 };
52 crate::redact::redact(&message)
53}
54
55pub fn is_transient(status: StatusCode) -> bool {
56 matches!(status.as_u16(), 408 | 429 | 500..=599)
57}
58
59pub fn retry_delay(attempt: u32, headers: Option<&HeaderMap>) -> Duration {
60 let retry_after = headers
61 .and_then(|headers| headers.get(RETRY_AFTER))
62 .and_then(|value| value.to_str().ok())
63 .and_then(|value| value.trim().parse::<u64>().ok())
64 .map(Duration::from_secs);
65 match retry_after {
66 Some(delay) => delay.min(Duration::from_secs(15)),
67 None => match attempt {
68 0 => Duration::from_millis(200),
69 1 => Duration::from_secs(1),
70 _ => Duration::from_secs(4),
71 },
72 }
73}
74
75fn builder(config: &HttpConfig) -> Result<reqwest::ClientBuilder> {
76 let _ = rustls::crypto::ring::default_provider().install_default();
78 let mut builder = reqwest::Client::builder()
79 .user_agent(user_agent())
80 .connect_timeout(Duration::from_secs(10))
81 .gzip(true)
82 .redirect(redirect_policy());
83 if !config.timeout.is_zero() {
84 builder = builder.timeout(config.timeout);
85 }
86 if let Some(path) = &config.ca_file {
87 let invalid = |message: String| YtError::Config {
88 path: path.display().to_string(),
89 message,
90 };
91 let pem = std::fs::read(path).map_err(|error| invalid(error.to_string()))?;
92 let certificates = reqwest::Certificate::from_pem_bundle(&pem)
93 .map_err(|error| invalid(format!("not a PEM certificate bundle: {error}")))?;
94 builder = builder.tls_certs_merge(certificates);
95 }
96 Ok(builder)
97}
98
99fn redirect_policy() -> reqwest::redirect::Policy {
101 reqwest::redirect::Policy::custom(|attempt| {
102 let previous = attempt.previous();
103 if previous
104 .last()
105 .is_some_and(|from| downgrades(from, attempt.url()))
106 {
107 attempt.error("refused a redirect from https to http")
108 } else if previous.len() > 10 {
109 attempt.error("too many redirects")
110 } else {
111 attempt.follow()
112 }
113 })
114}
115
116fn downgrades(from: &reqwest::Url, to: &reqwest::Url) -> bool {
117 from.scheme() == "https" && to.scheme() == "http"
118}
119
120fn build(builder: reqwest::ClientBuilder) -> Result<reqwest::Client> {
121 builder
122 .build()
123 .map_err(|error| YtError::Network(error.to_string()).into())
124}
125
126#[cfg(test)]
127mod tests {
128 use reqwest::header::HeaderValue;
129
130 use super::*;
131
132 fn config() -> HttpConfig {
133 HttpConfig {
134 timeout: Duration::from_secs(5),
135 retries: 3,
136 ca_file: None,
137 }
138 }
139
140 #[test]
141 fn user_agent_names_the_version_and_platform() {
142 let agent = user_agent();
143 assert!(
144 agent.starts_with(&format!("yt/{} (", env!("CARGO_PKG_VERSION"))),
145 "{agent}"
146 );
147 assert!(agent.contains(std::env::consts::OS), "{agent}");
148 }
149
150 #[test]
151 fn transient_statuses() {
152 for status in [408, 429, 500, 502, 503, 504] {
153 assert!(
154 is_transient(StatusCode::from_u16(status).unwrap()),
155 "{status}"
156 );
157 }
158 for status in [400, 401, 403, 404, 409] {
159 assert!(
160 !is_transient(StatusCode::from_u16(status).unwrap()),
161 "{status}"
162 );
163 }
164 }
165
166 #[test]
167 fn retry_delays_back_off_and_honour_retry_after() {
168 assert_eq!(retry_delay(0, None), Duration::from_millis(200));
169 assert_eq!(retry_delay(1, None), Duration::from_secs(1));
170 assert_eq!(retry_delay(5, None), Duration::from_secs(4));
171 let mut headers = HeaderMap::new();
172 headers.insert(RETRY_AFTER, HeaderValue::from_static("2"));
173 assert_eq!(retry_delay(0, Some(&headers)), Duration::from_secs(2));
174 headers.insert(RETRY_AFTER, HeaderValue::from_static("120"));
175 assert_eq!(retry_delay(0, Some(&headers)), Duration::from_secs(15));
176 headers.insert(
177 RETRY_AFTER,
178 HeaderValue::from_static("Wed, 21 Oct 2026 07:28:00 GMT"),
179 );
180 assert_eq!(retry_delay(1, Some(&headers)), Duration::from_secs(1));
181 }
182
183 #[test]
184 fn only_a_redirect_from_https_to_http_is_a_downgrade() {
185 let url = |text: &str| reqwest::Url::parse(text).unwrap();
186 assert!(downgrades(
187 &url("https://acme.example/api/x"),
188 &url("http://acme.example/api/x")
189 ));
190 for (from, to) in [
191 ("https://acme.example/a", "https://acme.example/b"),
192 ("http://acme.example/a", "http://acme.example/b"),
193 ("http://acme.example/a", "https://acme.example/a"),
194 ] {
195 assert!(!downgrades(&url(from), &url(to)), "{from} -> {to}");
196 }
197 }
198
199 #[test]
200 fn clients_build_without_an_installed_provider() {
201 client(&config()).unwrap();
202 client_without_redirects(&config()).unwrap();
203 }
204
205 #[test]
206 fn a_missing_ca_file_is_a_config_error() {
207 let error = client(&HttpConfig {
208 ca_file: Some("/nonexistent/ca.pem".into()),
209 ..config()
210 })
211 .unwrap_err();
212 assert!(matches!(
213 error.downcast_ref::<YtError>(),
214 Some(YtError::Config { .. })
215 ));
216 }
217
218 #[test]
219 fn settings_map_to_config_and_retries_are_capped() {
220 let settings = Settings {
221 http_timeout: "5s".into(),
222 http_retries: 99,
223 color: "auto".into(),
224 cache_ttl: "1h".into(),
225 };
226 let config = HttpConfig::from_settings(&settings, None).unwrap();
227 assert_eq!(config.timeout, Duration::from_secs(5));
228 assert_eq!(config.retries, 10);
229 }
230}