1use crate::client::api::SashikoApi;
4use crate::client::error::ApiError;
5use crate::client::types::{ListParams, RetryConfig, ReviewQuery};
6use crate::config::RemoteConfig;
7use crate::models::{
8 EmailMessage, MailingList, Paginated, PatchId, Patchset, PatchsetDetail, ServerStats,
9};
10use serde::de::DeserializeOwned;
11use std::time::Duration;
12use url::Url;
13
14pub struct HttpClient {
19 inner: reqwest::Client,
20 base_url: Url,
21 remote_name: String,
22 retry_config: RetryConfig,
23}
24
25impl HttpClient {
26 pub fn new(config: &RemoteConfig) -> Result<Self, ApiError> {
33 let client = reqwest::Client::builder()
34 .timeout(Duration::from_secs(config.timeout_seconds))
35 .build()
36 .map_err(|e| ApiError::Configuration(e.to_string()))?;
37
38 let base_url =
39 Url::parse(&config.url).map_err(|e| ApiError::Configuration(e.to_string()))?;
40
41 Ok(Self {
42 inner: client,
43 base_url,
44 remote_name: config.name.clone(),
45 retry_config: RetryConfig::from_remote(config),
46 })
47 }
48
49 async fn get<T: DeserializeOwned>(
51 &self,
52 path: &str,
53 query: &[(&str, String)],
54 ) -> Result<T, ApiError> {
55 let url = self
56 .base_url
57 .join(path)
58 .map_err(|e| ApiError::Configuration(format!("bad path '{path}': {e}")))?;
59
60 let mut last_error = None;
61 let max_attempts = self.retry_config.max_retries + 1;
62
63 for attempt in 0..max_attempts {
64 if attempt > 0 {
65 let delay = self.compute_backoff(attempt);
66 tokio::time::sleep(delay).await;
67 }
68
69 let result = self.inner.get(url.clone()).query(query).send().await;
70
71 match result {
72 Ok(response) => {
73 let status = response.status();
74
75 if status.is_success() {
76 return response
77 .json::<T>()
78 .await
79 .map_err(|e| make_deser_error(&e, path));
80 }
81
82 let status_code = status.as_u16();
83
84 if is_retryable_status(status_code) {
86 let body = response.text().await.ok();
87 last_error = Some(ApiError::HttpStatus {
88 status: status_code,
89 body,
90 remote: self.remote_name.clone(),
91 });
92 continue;
93 }
94
95 let body = response.text().await.ok();
97 return Err(ApiError::HttpStatus {
98 status: status_code,
99 body,
100 remote: self.remote_name.clone(),
101 });
102 }
103 Err(e) => {
104 if e.is_timeout() {
105 return Err(ApiError::Timeout {
106 endpoint: path.to_string(),
107 duration: Duration::from_secs(15),
108 });
109 }
110
111 last_error = Some(ApiError::Network {
113 source: Box::new(e),
114 remote: self.remote_name.clone(),
115 });
116 }
117 }
118 }
119
120 Err(last_error.unwrap_or_else(|| ApiError::Network {
121 source: "exhausted retries with no error captured".into(),
122 remote: self.remote_name.clone(),
123 }))
124 }
125
126 #[allow(clippy::cast_precision_loss)]
128 fn compute_backoff(&self, attempt: u32) -> Duration {
129 #[allow(clippy::cast_possible_wrap)]
130 let exponent = attempt.saturating_sub(1).min(30) as i32;
131 let factor = self.retry_config.backoff_factor.powi(exponent);
132 let base = self.retry_config.base_delay_ms as f64;
133 let delay_ms = (base * factor).min(self.retry_config.max_delay_ms as f64);
134
135 let jitter_range = delay_ms * 0.25;
137 let nanos = f64::from(
138 std::time::SystemTime::now()
139 .duration_since(std::time::UNIX_EPOCH)
140 .map_or(0, |d| d.subsec_nanos()),
141 );
142 let jitter = (nanos / f64::from(u32::MAX)) * 2.0 * jitter_range - jitter_range;
143
144 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
145 let ms = (delay_ms + jitter).max(0.0) as u64;
146 Duration::from_millis(ms)
147 }
148
149 fn list_query(params: &ListParams) -> Vec<(&'static str, String)> {
154 let mut query = vec![
155 ("page", params.page.to_string()),
156 ("limit", params.per_page.to_string()),
157 ];
158 if let Some(ref q) = params.search {
159 query.push(("q", q.clone()));
160 }
161 if let Some(ref ml) = params.mailing_list {
162 query.push(("list", ml.clone()));
163 }
164 query
165 }
166
167 fn id_query(id: &PatchId) -> Vec<(&'static str, String)> {
169 vec![("id", id.to_string())]
170 }
171
172 fn review_query(params: &ReviewQuery) -> Vec<(&'static str, String)> {
174 let mut query = Vec::new();
175 if let Some(id) = params.id {
176 query.push(("id", id.to_string()));
177 }
178 if let Some(ps_id) = params.patchset_id {
179 query.push(("patchset_id", ps_id.to_string()));
180 }
181 query
182 }
183}
184
185const fn is_retryable_status(status: u16) -> bool {
187 matches!(status, 500 | 502 | 503 | 504)
188}
189
190fn make_deser_error(e: &reqwest::Error, endpoint: &str) -> ApiError {
192 ApiError::Deserialization {
193 message: e.to_string(),
194 endpoint: endpoint.to_string(),
195 }
196}
197
198#[async_trait::async_trait]
199impl SashikoApi for HttpClient {
200 async fn lists(&self) -> Result<Vec<MailingList>, ApiError> {
201 self.get("/api/lists", &[]).await
202 }
203
204 async fn patchsets(&self, params: &ListParams) -> Result<Paginated<Patchset>, ApiError> {
205 self.get("/api/patchsets", &Self::list_query(params)).await
206 }
207
208 async fn messages(&self, params: &ListParams) -> Result<Paginated<EmailMessage>, ApiError> {
209 self.get("/api/messages", &Self::list_query(params)).await
210 }
211
212 async fn patch_detail(&self, id: &PatchId) -> Result<PatchsetDetail, ApiError> {
213 self.get("/api/patch", &Self::id_query(id)).await
214 }
215
216 async fn patchset_summary(&self, id: &PatchId) -> Result<PatchsetDetail, ApiError> {
217 self.get("/api/patchset", &Self::id_query(id)).await
218 }
219
220 async fn message_detail(&self, id: &PatchId) -> Result<EmailMessage, ApiError> {
221 self.get("/api/message", &Self::id_query(id)).await
222 }
223
224 async fn review(&self, params: &ReviewQuery) -> Result<serde_json::Value, ApiError> {
225 self.get("/api/review", &Self::review_query(params)).await
226 }
227
228 async fn review_log(&self, params: &ReviewQuery) -> Result<serde_json::Value, ApiError> {
229 self.get("/api/review_log", &Self::review_query(params))
230 .await
231 }
232
233 async fn stats(&self) -> Result<ServerStats, ApiError> {
234 self.get("/api/stats", &[]).await
235 }
236
237 async fn stats_timeline(
238 &self,
239 subsystem_id: Option<i64>,
240 ) -> Result<serde_json::Value, ApiError> {
241 let query: Vec<(&str, String)> = subsystem_id
242 .map(|id| vec![("subsystem_id", id.to_string())])
243 .unwrap_or_default();
244 self.get("/api/stats/timeline", &query).await
245 }
246
247 async fn stats_reviews(&self) -> Result<serde_json::Value, ApiError> {
248 self.get("/api/stats/reviews", &[]).await
249 }
250
251 async fn stats_tools(&self) -> Result<serde_json::Value, ApiError> {
252 self.get("/api/stats/tools", &[]).await
253 }
254}
255
256#[cfg(test)]
257#[allow(clippy::expect_used)]
258mod tests {
259 use super::*;
260 use crate::config::RemoteConfig;
261
262 fn test_remote() -> RemoteConfig {
263 RemoteConfig {
264 name: "test".to_string(),
265 url: "https://sashiko.example.com".to_string(),
266 auth_env: None,
267 timeout_seconds: 15,
268 max_retries: 3,
269 }
270 }
271
272 #[test]
273 fn new_client_with_valid_config() {
274 let client = HttpClient::new(&test_remote());
275 assert!(client.is_ok());
276 }
277
278 #[test]
279 fn new_client_with_invalid_url() {
280 let config = RemoteConfig {
281 name: "bad".to_string(),
282 url: "not-a-url".to_string(),
283 auth_env: None,
284 timeout_seconds: 15,
285 max_retries: 3,
286 };
287 let result = HttpClient::new(&config);
288 assert!(result.is_err());
289 assert!(matches!(result, Err(ApiError::Configuration(_))));
290 }
291
292 #[test]
293 fn list_query_builds_params() {
294 let params = ListParams {
295 page: 2,
296 per_page: 25,
297 search: Some("null deref".to_string()),
298 mailing_list: Some("LKML".to_string()),
299 };
300 let query = HttpClient::list_query(¶ms);
301 assert_eq!(query.len(), 4);
302 assert_eq!(query[0], ("page", "2".to_string()));
303 assert_eq!(query[1], ("limit", "25".to_string()));
304 assert_eq!(query[2], ("q", "null deref".to_string()));
305 assert_eq!(query[3], ("list", "LKML".to_string()));
306 }
307
308 #[test]
309 fn id_query_numeric() {
310 let query = HttpClient::id_query(&PatchId::Numeric(42));
311 assert_eq!(query, vec![("id", "42".to_string())]);
312 }
313
314 #[test]
315 fn id_query_message_id() {
316 let query = HttpClient::id_query(&PatchId::MessageId("foo@example.com".to_string()));
317 assert_eq!(query, vec![("id", "foo@example.com".to_string())]);
318 }
319
320 #[test]
321 fn retryable_status_codes() {
322 assert!(is_retryable_status(500));
323 assert!(is_retryable_status(502));
324 assert!(is_retryable_status(503));
325 assert!(is_retryable_status(504));
326 assert!(!is_retryable_status(400));
327 assert!(!is_retryable_status(404));
328 assert!(!is_retryable_status(200));
329 }
330
331 #[test]
332 fn backoff_increases_with_attempts() {
333 let client = HttpClient::new(&test_remote()).expect("build client");
334 let d1 = client.compute_backoff(1);
335 let d2 = client.compute_backoff(2);
336 let d3 = client.compute_backoff(3);
337 assert!(d2 > d1 / 2, "d2 ({d2:?}) should be > d1/2 ({:?})", d1 / 2);
339 assert!(d3 > d2 / 2, "d3 ({d3:?}) should be > d2/2 ({:?})", d2 / 2);
340 }
341}