Skip to main content

github_actions_maintainer/
crates_io.rs

1use std::{thread, time::Duration};
2
3use anyhow::{Context, Result, anyhow, bail};
4use reqwest::{
5    StatusCode,
6    blocking::{Client, RequestBuilder, Response},
7    header::{HeaderMap, HeaderValue, RETRY_AFTER, USER_AGENT},
8};
9use semver::Version;
10use serde::Deserialize;
11
12#[derive(Debug, Clone)]
13pub struct CratesIoClient {
14    base_url: String,
15    client: Client,
16    max_retries: u32,
17    retry_delay: Duration,
18    max_retry_delay: Duration,
19}
20
21#[derive(Debug, Clone)]
22pub struct CratesIoClientOptions {
23    pub base_url: String,
24    pub timeout: Duration,
25    pub max_retries: u32,
26    pub retry_delay: Duration,
27    pub max_retry_delay: Duration,
28}
29
30impl Default for CratesIoClientOptions {
31    fn default() -> Self {
32        Self {
33            base_url: String::from("https://crates.io/api/v1"),
34            timeout: Duration::from_secs(30),
35            max_retries: 3,
36            retry_delay: Duration::from_secs(1),
37            max_retry_delay: Duration::from_secs(30),
38        }
39    }
40}
41
42#[derive(Debug, Deserialize)]
43struct CrateMetadataResponse {
44    #[serde(rename = "crate")]
45    metadata: CrateMetadata,
46}
47
48#[derive(Debug, Deserialize)]
49struct CrateMetadata {
50    #[serde(rename = "max_version")]
51    latest: Option<String>,
52    #[serde(rename = "max_stable_version")]
53    latest_stable: Option<String>,
54    #[serde(rename = "newest_version")]
55    newest: Option<String>,
56}
57
58impl CratesIoClient {
59    pub fn new(base_url: impl Into<String>) -> Result<Self> {
60        Self::with_options(&CratesIoClientOptions {
61            base_url: base_url.into(),
62            ..CratesIoClientOptions::default()
63        })
64    }
65
66    pub fn with_options(options: &CratesIoClientOptions) -> Result<Self> {
67        let mut headers = HeaderMap::new();
68        headers.insert(USER_AGENT, HeaderValue::from_static("github-actions-maintainer"));
69
70        let client = Client::builder()
71            .default_headers(headers)
72            .timeout(options.timeout)
73            .build()
74            .context("failed to build crates.io HTTP client")?;
75
76        Ok(Self {
77            base_url: options.base_url.trim_end_matches('/').to_owned(),
78            client,
79            max_retries: options.max_retries,
80            retry_delay: options.retry_delay,
81            max_retry_delay: options.max_retry_delay,
82        })
83    }
84
85    pub fn latest_stable_version(&self, crate_name: &str) -> Result<String> {
86        let encoded = urlencoding::encode(crate_name);
87        let response = self.send_with_retry(
88            || self.client.get(format!("{}/crates/{encoded}", self.base_url)),
89            || format!("fetch crates.io metadata for {crate_name}"),
90        )?;
91        let metadata = response
92            .json::<CrateMetadataResponse>()
93            .with_context(|| format!("failed to decode crates.io metadata for {crate_name}"))?;
94
95        select_stable_version(&metadata.metadata)
96            .ok_or_else(|| anyhow!("no stable crates.io release found for {crate_name}"))
97    }
98
99    fn send_with_retry<F, D>(&self, mut build_request: F, describe: D) -> Result<Response>
100    where
101        F: FnMut() -> RequestBuilder,
102        D: Fn() -> String,
103    {
104        let mut attempt = 0u32;
105
106        loop {
107            match build_request().send() {
108                Ok(response) if response.status().is_success() => return Ok(response),
109                Ok(response) => {
110                    if should_retry_response(&response) && attempt < self.max_retries {
111                        sleep_for_retry(
112                            response.headers(),
113                            attempt,
114                            self.retry_delay,
115                            self.max_retry_delay,
116                        );
117                        attempt += 1;
118                        continue;
119                    }
120                    return error_from_response(response, &describe());
121                }
122                Err(error) => {
123                    if (error.is_timeout() || error.is_connect()) && attempt < self.max_retries {
124                        thread::sleep(calculate_backoff(
125                            self.retry_delay,
126                            self.max_retry_delay,
127                            attempt,
128                        ));
129                        attempt += 1;
130                        continue;
131                    }
132                    return Err(error).with_context(describe);
133                }
134            }
135        }
136    }
137}
138
139fn select_stable_version(metadata: &CrateMetadata) -> Option<String> {
140    [metadata.latest_stable.as_deref(), metadata.latest.as_deref(), metadata.newest.as_deref()]
141        .into_iter()
142        .flatten()
143        .find(|candidate| is_stable_version(candidate))
144        .map(ToOwned::to_owned)
145}
146
147fn is_stable_version(candidate: &str) -> bool {
148    Version::parse(candidate).is_ok_and(|version| version.pre.is_empty())
149}
150
151fn should_retry_response(response: &Response) -> bool {
152    response.status() == StatusCode::TOO_MANY_REQUESTS || response.status().is_server_error()
153}
154
155fn sleep_for_retry(
156    headers: &HeaderMap,
157    attempt: u32,
158    retry_delay: Duration,
159    max_retry_delay: Duration,
160) {
161    let delay = retry_delay_from_headers(headers)
162        .filter(|delay| *delay > Duration::ZERO && *delay <= max_retry_delay * 10)
163        .unwrap_or_else(|| calculate_backoff(retry_delay, max_retry_delay, attempt));
164    thread::sleep(delay);
165}
166
167fn calculate_backoff(retry_delay: Duration, max_retry_delay: Duration, attempt: u32) -> Duration {
168    let shift = attempt.min(10);
169    let candidate = retry_delay.saturating_mul(1u32 << shift);
170    candidate.min(max_retry_delay)
171}
172
173fn retry_delay_from_headers(headers: &HeaderMap) -> Option<Duration> {
174    headers
175        .get(RETRY_AFTER)
176        .and_then(|value| value.to_str().ok())
177        .and_then(|value| value.parse::<u64>().ok())
178        .map(Duration::from_secs)
179}
180
181fn error_from_response(response: Response, context: &str) -> Result<Response> {
182    let status = response.status();
183    let body = response.text().unwrap_or_else(|_| String::from("<response body unavailable>"));
184
185    if status == StatusCode::NOT_FOUND {
186        bail!("{context}: crate not found ({body})");
187    }
188
189    bail!("{context}: crates.io API returned {status} ({body})")
190}
191
192#[cfg(test)]
193#[allow(clippy::significant_drop_tightening)]
194mod tests {
195    use std::time::Duration;
196
197    use mockito::Server;
198
199    use super::{CratesIoClient, CratesIoClientOptions};
200
201    #[test]
202    fn latest_stable_version_prefers_max_stable_version() {
203        let mut server = Server::new();
204        let _crate = server
205            .mock("GET", "/crates/reqwest")
206            .match_header("user-agent", "github-actions-maintainer")
207            .with_status(200)
208            .with_body(
209                r#"{
210                    "crate": {
211                        "id": "reqwest",
212                        "name": "reqwest",
213                        "max_version": "0.14.0-beta.1",
214                        "max_stable_version": "0.13.2",
215                        "newest_version": "0.14.0-beta.1"
216                    }
217                }"#,
218            )
219            .create();
220
221        let client = CratesIoClient::new(server.url()).expect("crates.io client");
222        let latest = client.latest_stable_version("reqwest").expect("latest stable version");
223
224        assert_eq!(latest, "0.13.2");
225    }
226
227    #[test]
228    fn latest_stable_version_retries_after_rate_limit() {
229        let mut server = Server::new();
230        let _rate_limited = server
231            .mock("GET", "/crates/serde")
232            .expect(1)
233            .match_header("user-agent", "github-actions-maintainer")
234            .with_status(429)
235            .with_header("retry-after", "0")
236            .with_body(r#"{"errors":[{"detail":"slow down"}]}"#)
237            .create();
238        let _success = server
239            .mock("GET", "/crates/serde")
240            .expect(1)
241            .match_header("user-agent", "github-actions-maintainer")
242            .with_status(200)
243            .with_body(
244                r#"{
245                    "crate": {
246                        "id": "serde",
247                        "name": "serde",
248                        "max_version": "1.0.219",
249                        "max_stable_version": "1.0.219",
250                        "newest_version": "1.0.219"
251                    }
252                }"#,
253            )
254            .create();
255
256        let client = CratesIoClient::with_options(&CratesIoClientOptions {
257            base_url: server.url(),
258            timeout: Duration::from_secs(5),
259            max_retries: 1,
260            retry_delay: Duration::from_millis(1),
261            max_retry_delay: Duration::from_millis(5),
262        })
263        .expect("crates.io client");
264        let latest = client.latest_stable_version("serde").expect("latest stable version");
265
266        assert_eq!(latest, "1.0.219");
267    }
268}