aspero_engine/
transport.rs1use std::sync::Arc;
4use std::time::Duration;
5
6use aspero_core::http::Method;
7use aspero_core::{Error, HttpClient, HttpRequest, HttpResponse};
8use async_trait::async_trait;
9use jiff::Timestamp;
10use tokio::sync::Mutex;
11use tokio::time::Instant;
12
13pub struct ReqwestClient {
16 client: reqwest::Client,
17}
18
19impl ReqwestClient {
20 pub fn new(user_agent: &str, attempt_timeout: Duration) -> Result<Self, Error> {
21 let client = reqwest::Client::builder()
22 .user_agent(user_agent)
23 .timeout(attempt_timeout)
24 .build()
25 .map_err(Error::network)?;
26 Ok(ReqwestClient { client })
27 }
28}
29
30#[async_trait]
31impl HttpClient for ReqwestClient {
32 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Error> {
33 let method = match request.method {
34 Method::Post => reqwest::Method::POST,
35 _ => reqwest::Method::GET,
36 };
37 let mut builder = self.client.request(method, &request.url);
38 for (name, value) in &request.headers {
39 builder = builder.header(name, value);
40 }
41 if let Some(body) = request.body {
42 builder = builder.body(body);
43 }
44 let resp = builder.send().await.map_err(transport_error)?;
45 let status = resp.status().as_u16();
46 let headers = resp
47 .headers()
48 .iter()
49 .filter_map(|(k, v)| Some((k.as_str().to_owned(), v.to_str().ok()?.to_owned())))
50 .collect();
51 let body = resp.bytes().await.map_err(transport_error)?.to_vec();
52 Ok(HttpResponse::new(status, headers, body, Timestamp::now()))
53 }
54}
55
56fn transport_error(e: reqwest::Error) -> Error {
57 if e.is_timeout() {
58 Error::Timeout
59 } else {
60 Error::network(e)
61 }
62}
63
64pub struct Paced {
72 label: String,
73 inner: Arc<dyn HttpClient>,
74 min_interval: Duration,
75 next_slot: Mutex<Option<Instant>>,
76}
77
78impl Paced {
79 pub fn new(
80 label: impl Into<String>,
81 inner: Arc<dyn HttpClient>,
82 min_interval: Duration,
83 ) -> Self {
84 Paced {
85 label: label.into(),
86 inner,
87 min_interval,
88 next_slot: Mutex::new(None),
89 }
90 }
91
92 async fn claim(&self) -> Instant {
94 let mut next = self.next_slot.lock().await;
95 let now = Instant::now();
96 let slot = next.map_or(now, |n| n.max(now));
97 *next = Some(slot + self.min_interval);
98 slot
99 }
100}
101
102#[async_trait]
103impl HttpClient for Paced {
104 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Error> {
105 let queued = Instant::now();
106 let slot = self.claim().await;
107 tokio::time::sleep_until(slot).await;
108 let started = Instant::now();
109 let url = loggable_url(&request.url);
110 let result = self.inner.send(request).await;
111 let ms = |since: Instant, until: Instant| until.duration_since(since).as_millis() as u64;
112 let (waited_ms, took_ms) = (ms(queued, started), ms(started, Instant::now()));
113 match &result {
114 Ok(resp) => tracing::info!(
115 target: "aspero::http",
116 provider = %self.label, status = resp.status, took_ms, waited_ms, "{url}"
117 ),
118 Err(e) => tracing::warn!(
119 target: "aspero::http",
120 provider = %self.label, error = %e, took_ms, waited_ms, "{url}"
121 ),
122 }
123 result
124 }
125}
126
127fn loggable_url(url: &str) -> String {
130 let path = url
131 .split_once("://")
132 .map_or(url, |(_, rest)| rest.find('/').map_or("/", |i| &rest[i..]));
133 let redacted: String = match path.split_once('?') {
134 None => path.to_owned(),
135 Some((p, query)) => {
136 let params: Vec<String> = query
137 .split('&')
138 .map(|kv| match kv.split_once('=') {
139 Some((k, _)) if matches!(k, "mailto" | "api_key" | "key") => format!("{k}=…"),
140 _ => kv.to_owned(),
141 })
142 .collect();
143 format!("{p}?{}", params.join("&"))
144 }
145 };
146 const MAX: usize = 140;
147 match redacted.char_indices().nth(MAX) {
148 Some((cut, _)) => format!("{}…", &redacted[..cut]),
149 None => redacted,
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 struct Echo;
158
159 #[async_trait]
160 impl HttpClient for Echo {
161 async fn send(&self, _: HttpRequest) -> Result<HttpResponse, Error> {
162 Ok(HttpResponse::new(
163 200,
164 vec![],
165 vec![],
166 Timestamp::UNIX_EPOCH,
167 ))
168 }
169 }
170
171 #[tokio::test(start_paused = true)]
172 async fn paced_requests_start_min_interval_apart() {
173 let paced = Arc::new(Paced::new(
174 "test",
175 Arc::new(Echo),
176 Duration::from_millis(250),
177 ));
178 let start = Instant::now();
179 let mut tasks = Vec::new();
180 for _ in 0..4 {
181 let paced = paced.clone();
182 tasks.push(tokio::spawn(async move {
183 paced.send(HttpRequest::get("http://x")).await.unwrap();
184 start.elapsed().as_millis()
185 }));
186 }
187 let mut starts = Vec::new();
188 for t in tasks {
189 starts.push(t.await.unwrap());
190 }
191 starts.sort_unstable();
192 assert_eq!(starts, [0, 250, 500, 750]);
193 }
194
195 #[test]
196 fn logged_urls_hide_contacts_and_keys() {
197 assert_eq!(
198 loggable_url("https://api.crossref.org/works?query=x&mailto=me%40example.org&rows=3"),
199 "/works?query=x&mailto=…&rows=3"
200 );
201 assert_eq!(
202 loggable_url("https://doi.org/10.1038/nature14539"),
203 "/10.1038/nature14539"
204 );
205 }
206
207 #[tokio::test(start_paused = true)]
208 async fn idle_time_is_not_banked() {
209 let paced = Paced::new("test", Arc::new(Echo), Duration::from_millis(100));
210 paced.send(HttpRequest::get("http://x")).await.unwrap();
211 tokio::time::sleep(Duration::from_secs(5)).await;
212 let start = Instant::now();
213 paced.send(HttpRequest::get("http://x")).await.unwrap();
214 paced.send(HttpRequest::get("http://x")).await.unwrap();
215 assert_eq!(start.elapsed().as_millis(), 100);
216 }
217}