Skip to main content

yt/
http.rs

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
32/// For requests whose redirect is the answer, such as the sign-in preflight.
33pub 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    // reqwest is built without a default crypto provider; tests reach this without going through main.
68    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}