quilt_rs/io/remote/
client.rs1use 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 #[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
81struct 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 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
131const ERROR_BODY_LOG_LIMIT: usize = 500;
135
136async 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 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 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 let response: Config = client
269 .get("https://open.quiltdata.com/config.json", None)
270 .await?;
271
272 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 #[test]
347 fn truncate_never_splits_multibyte_chars() {
348 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}