github_actions_maintainer/
crates_io.rs1use 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}