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
46/// A failed request as the user reads it; why a redirect was stopped is only in the error's source.
47pub 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    // reqwest is built without a default crypto provider; tests reach this without going through main.
77    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
99/// reqwest keeps `Authorization` on a redirect to the same host and port, even from https to http.
100fn 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}