Skip to main content

quilt_rs/io/remote/
client.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use reqwest::header::HeaderMap;
6use reqwest_middleware::ClientBuilder;
7use reqwest_middleware::ClientWithMiddleware;
8use reqwest_retry::DefaultRetryableStrategy;
9use reqwest_retry::RetryTransientMiddleware;
10use reqwest_retry::Retryable;
11use reqwest_retry::RetryableStrategy;
12use reqwest_retry::policies::ExponentialBackoff;
13use serde::de::DeserializeOwned;
14use tracing::warn;
15
16use crate::Error;
17use crate::Res;
18
19const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
20const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
21const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
22const MAX_RETRIES: u32 = 2;
23
24#[async_trait]
25pub trait HttpClient: Send + Sync {
26    async fn get<T: DeserializeOwned>(&self, url: &str, auth_token: Option<&str>) -> Res<T>;
27    async fn head(&self, url: &str) -> Res<HeaderMap>;
28    async fn post<T: DeserializeOwned>(
29        &self,
30        url: &str,
31        form_data: &HashMap<String, String>,
32    ) -> Res<T>;
33    async fn post_json<T: DeserializeOwned, B: serde::Serialize + Send + Sync>(
34        &self,
35        url: &str,
36        body: &B,
37    ) -> Res<T>;
38    async fn post_json_auth<T: DeserializeOwned, B: serde::Serialize + Send + Sync>(
39        &self,
40        url: &str,
41        body: &B,
42        auth_token: &str,
43    ) -> Res<T>;
44}
45
46#[derive(Clone, Debug)]
47pub struct ReqwestClient {
48    client: ClientWithMiddleware,
49}
50
51impl Default for ReqwestClient {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl ReqwestClient {
58    /// # Panics
59    ///
60    /// Panics if building the underlying `reqwest` client fails, which should
61    /// not happen with the default TLS configuration.
62    #[must_use]
63    pub fn new() -> Self {
64        let inner = reqwest::Client::builder()
65            .timeout(REQUEST_TIMEOUT)
66            .connect_timeout(CONNECT_TIMEOUT)
67            .pool_idle_timeout(POOL_IDLE_TIMEOUT)
68            .build()
69            .expect("reqwest client build should not fail with default TLS config");
70
71        let retry_policy = ExponentialBackoff::builder().build_with_max_retries(MAX_RETRIES);
72        let retry_middleware =
73            RetryTransientMiddleware::new_with_policy_and_strategy(retry_policy, LoggingStrategy);
74
75        let client = ClientBuilder::new(inner).with(retry_middleware).build();
76
77        Self { client }
78    }
79}
80
81/// Wraps [`DefaultRetryableStrategy`] with a `warn!` on every attempt the retry
82/// middleware classifies as transient. Gives us a flakiness signal in logs
83/// without standing up dedicated telemetry.
84///
85/// Fires on the *final* attempt too β€” reqwest-retry asks the strategy before
86/// checking whether any attempts remain, so "may retry" is honest: retry
87/// happens only if the attempt count hasn't been exhausted.
88struct LoggingStrategy;
89
90impl RetryableStrategy for LoggingStrategy {
91    fn handle(
92        &self,
93        res: &Result<reqwest::Response, reqwest_middleware::Error>,
94    ) -> Option<Retryable> {
95        let decision = DefaultRetryableStrategy.handle(res);
96        if matches!(decision, Some(Retryable::Transient)) {
97            match res {
98                Ok(resp) => warn!(
99                    status = resp.status().as_u16(),
100                    url = %resp.url(),
101                    "πŸ” transient HTTP response β€” may retry"
102                ),
103                Err(e) => warn!(
104                    error = %e,
105                    "πŸ” transient HTTP error β€” may retry"
106                ),
107            }
108        }
109        decision
110    }
111}
112
113impl From<reqwest_middleware::Error> for Error {
114    fn from(err: reqwest_middleware::Error) -> Self {
115        match err {
116            reqwest_middleware::Error::Reqwest(e) => Error::Reqwest(e),
117            // `Middleware(anyhow::Error)` is only produced if a middleware
118            // layer itself fails (not the HTTP exchange). Our only middleware
119            // is the retry layer, which doesn't surface errors this way; fold
120            // into `Error::Io` so callers don't need a new match arm.
121            reqwest_middleware::Error::Middleware(e) => {
122                Error::Io(std::io::Error::other(e.to_string()))
123            }
124        }
125    }
126}
127
128const USER_AGENT: &str =
129    "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.0.3705; .NET CLR 1.1.4322)";
130
131/// Max bytes of response body to include in error log lines. Enough for an
132/// RFC 6749 Β§5.2 error payload (`{"error":"invalid_grant",...}`) or a short
133/// server error page, without flooding logs with a full HTML response.
134const ERROR_BODY_LOG_LIMIT: usize = 500;
135
136/// On non-2xx responses, reads and logs the status/url/body before returning
137/// the reqwest error. Keeps the response body β€” which `error_for_status`
138/// would otherwise discard β€” available for diagnostics.
139async fn ensure_success(response: reqwest::Response) -> Res<reqwest::Response> {
140    if response.status().is_success() {
141        return Ok(response);
142    }
143    let status = response.status();
144    let url = response.url().clone();
145    // Take the error via the non-consuming variant, then consume the response
146    // for its body.
147    let err = response
148        .error_for_status_ref()
149        .expect_err("status is non-success");
150    let body = response.text().await.unwrap_or_default();
151    warn!(
152        status = status.as_u16(),
153        url = %url,
154        body = %truncate_for_log(&body),
155        "❌ HTTP error response"
156    );
157    Err(err.into())
158}
159
160fn truncate_for_log(s: &str) -> String {
161    if s.len() <= ERROR_BODY_LOG_LIMIT {
162        return s.to_string();
163    }
164    let mut end = ERROR_BODY_LOG_LIMIT;
165    while end > 0 && !s.is_char_boundary(end) {
166        end -= 1;
167    }
168    format!("{}…[{} bytes total]", &s[..end], s.len())
169}
170
171#[async_trait]
172impl HttpClient for ReqwestClient {
173    async fn get<T: DeserializeOwned>(&self, url: &str, auth_token: Option<&str>) -> Res<T> {
174        let mut request = self.client.get(url).header("User-Agent", USER_AGENT);
175
176        if let Some(token) = auth_token {
177            request = request.bearer_auth(token);
178        }
179
180        let response = ensure_success(request.send().await?).await?;
181        Ok(response.json().await?)
182    }
183
184    // TODO: wire through `ensure_success` so non-2xx HEAD responses surface as
185    // errors instead of empty-header `Ok`.
186    async fn head(&self, url: &str) -> Res<HeaderMap> {
187        let response = self
188            .client
189            .head(url)
190            .header("User-Agent", USER_AGENT)
191            .send()
192            .await?;
193        Ok(response.headers().clone())
194    }
195
196    async fn post<T: DeserializeOwned>(
197        &self,
198        url: &str,
199        form_data: &HashMap<String, String>,
200    ) -> Res<T> {
201        let response = ensure_success(
202            self.client
203                .post(url)
204                .header("User-Agent", USER_AGENT)
205                .form(form_data)
206                .send()
207                .await?,
208        )
209        .await?;
210        Ok(response.json().await?)
211    }
212
213    async fn post_json<T: DeserializeOwned, B: serde::Serialize + Send + Sync>(
214        &self,
215        url: &str,
216        body: &B,
217    ) -> Res<T> {
218        let response = ensure_success(
219            self.client
220                .post(url)
221                .header("User-Agent", USER_AGENT)
222                .json(body)
223                .send()
224                .await?,
225        )
226        .await?;
227        Ok(response.json().await?)
228    }
229
230    async fn post_json_auth<T: DeserializeOwned, B: serde::Serialize + Send + Sync>(
231        &self,
232        url: &str,
233        body: &B,
234        auth_token: &str,
235    ) -> Res<T> {
236        let response = ensure_success(
237            self.client
238                .post(url)
239                .header("User-Agent", USER_AGENT)
240                .bearer_auth(auth_token)
241                .json(body)
242                .send()
243                .await?,
244        )
245        .await?;
246        Ok(response.json().await?)
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use test_log::test;
254
255    use serde::Deserialize;
256    use serde::Serialize;
257
258    #[test(tokio::test)]
259    async fn test_get_config() -> Res {
260        let client = ReqwestClient::new();
261
262        #[derive(Deserialize, Serialize)]
263        struct Config {
264            mode: String,
265        }
266
267        // Get the raw text content first to check for the QUILT_CATALOG_CONFIG string
268        let response: Config = client
269            .get("https://open.quiltdata.com/config.json", None)
270            .await?;
271
272        // Check that the config.js contains the QUILT_CATALOG_CONFIG string
273        assert_eq!(response.mode, "OPEN");
274
275        Ok(())
276    }
277
278    #[test(tokio::test)]
279    async fn post_json_auth_sends_bearer_token_and_json_body() -> Res {
280        use tokio::io::AsyncReadExt;
281        use tokio::io::AsyncWriteExt;
282
283        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
284        let addr = listener.local_addr()?;
285        let captured = tokio::spawn(async move {
286            let (mut stream, _) = listener.accept().await.unwrap();
287            let mut buf = vec![0u8; 4096];
288            let n = stream.read(&mut buf).await.unwrap();
289            let request = String::from_utf8_lossy(&buf[..n]).to_string();
290            let body = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
291                         Content-Length: 14\r\nConnection: close\r\n\r\n{\"ok\":\"yes\"}\n\n";
292            stream.write_all(&body[..]).await.unwrap();
293            stream.shutdown().await.unwrap();
294            request
295        });
296
297        #[derive(Deserialize)]
298        struct Reply {
299            ok: String,
300        }
301
302        #[derive(Serialize)]
303        struct Body {
304            query: String,
305        }
306
307        let client = ReqwestClient::new();
308        let reply: Reply = client
309            .post_json_auth(
310                &format!("http://{addr}/graphql"),
311                &Body {
312                    query: "{ me { name } }".to_string(),
313                },
314                "test-token",
315            )
316            .await?;
317
318        assert_eq!(reply.ok, "yes");
319        let request = captured.await.unwrap().to_lowercase();
320        assert!(
321            request.contains("authorization: bearer test-token"),
322            "bearer token missing from request: {request}"
323        );
324        assert!(
325            request.contains("content-type: application/json"),
326            "json content-type missing from request: {request}"
327        );
328        Ok(())
329    }
330
331    #[test]
332    fn truncate_short_body_is_unchanged() {
333        assert_eq!(truncate_for_log("hello"), "hello");
334    }
335
336    #[test]
337    fn truncate_long_body_is_cut_with_total_length() {
338        let s = "x".repeat(ERROR_BODY_LOG_LIMIT + 10);
339        let got = truncate_for_log(&s);
340        assert!(got.starts_with(&"x".repeat(ERROR_BODY_LOG_LIMIT)));
341        assert!(got.contains(&format!("[{} bytes total]", s.len())));
342    }
343
344    // `str` slicing must land on a char boundary β€” a multi-byte glyph at the
345    // cutoff would otherwise panic.
346    #[test]
347    fn truncate_never_splits_multibyte_chars() {
348        // "πŸ’₯" is 4 bytes; put one straddling the limit.
349        let prefix = "a".repeat(ERROR_BODY_LOG_LIMIT - 2);
350        let s = format!("{prefix}πŸ’₯trailing");
351        let got = truncate_for_log(&s);
352        assert!(got.contains(&prefix));
353        assert!(got.contains("bytes total"));
354    }
355}