Skip to main content

remendo/client/
http.rs

1//! Production HTTP client for Sashiko API access.
2
3use 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
14/// Production HTTP client for a single Sashiko remote.
15///
16/// Uses `reqwest` for async HTTP with connection pooling.
17/// Each remote gets its own `HttpClient` instance.
18pub struct HttpClient {
19    inner: reqwest::Client,
20    base_url: Url,
21    remote_name: String,
22    retry_config: RetryConfig,
23}
24
25impl HttpClient {
26    /// Construct a new client for the given remote configuration.
27    ///
28    /// # Errors
29    ///
30    /// Returns [`ApiError::Configuration`] if the URL is invalid or
31    /// the HTTP client cannot be built.
32    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    /// Execute a GET request with retry logic and JSON deserialization.
50    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                    // Retry on 5xx server errors
85                    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                    // Don't retry client errors (4xx)
96                    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                    // Network error — retry
112                    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    /// Compute backoff delay with jitter for the given attempt number.
127    #[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        // Simple jitter: +/- 25%
136        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    /// Build query parameters for list endpoints.
150    ///
151    /// Wire names follow the Sashiko API docs: `limit` (not `per_page`),
152    /// `list` (not `mailing_list`).
153    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    /// Build the `id` query parameter from a `PatchId`.
168    fn id_query(id: &PatchId) -> Vec<(&'static str, String)> {
169        vec![("id", id.to_string())]
170    }
171
172    /// Build query parameters for review endpoints.
173    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
185/// Check if an HTTP status code should trigger a retry.
186const fn is_retryable_status(status: u16) -> bool {
187    matches!(status, 500 | 502 | 503 | 504)
188}
189
190/// Convert a reqwest JSON error into an [`ApiError::Deserialization`].
191fn 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(&params);
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        // Each should be roughly 2x the previous (with jitter)
338        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}