1use std::sync::Arc;
4use std::time::Duration;
5
6use aspero_core::http::Method;
7use aspero_core::{Error, HttpClient, HttpRequest, HttpResponse};
8use aspero_store::Pacing;
9use async_trait::async_trait;
10use jiff::Timestamp;
11use tokio::sync::Mutex;
12use tokio::time::Instant;
13
14pub struct ReqwestClient {
17 client: reqwest::Client,
18}
19
20impl ReqwestClient {
21 pub fn new(user_agent: &str, attempt_timeout: Duration) -> Result<Self, Error> {
22 let client = reqwest::Client::builder()
23 .user_agent(user_agent)
24 .timeout(attempt_timeout)
25 .build()
26 .map_err(Error::network)?;
27 Ok(ReqwestClient { client })
28 }
29}
30
31#[async_trait]
32impl HttpClient for ReqwestClient {
33 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Error> {
34 let method = match request.method {
35 Method::Post => reqwest::Method::POST,
36 _ => reqwest::Method::GET,
37 };
38 let mut builder = self.client.request(method, &request.url);
39 for (name, value) in &request.headers {
40 builder = builder.header(name, value);
41 }
42 if let Some(body) = request.body {
43 builder = builder.body(body);
44 }
45 let resp = builder.send().await.map_err(transport_error)?;
46 let status = resp.status().as_u16();
47 let headers = resp
48 .headers()
49 .iter()
50 .filter_map(|(k, v)| Some((k.as_str().to_owned(), v.to_str().ok()?.to_owned())))
51 .collect();
52 let body = resp.bytes().await.map_err(transport_error)?.to_vec();
53 Ok(HttpResponse::new(status, headers, body, Timestamp::now()))
54 }
55}
56
57fn transport_error(e: reqwest::Error) -> Error {
58 if e.is_timeout() {
59 Error::Timeout
60 } else {
61 Error::network(e)
62 }
63}
64
65pub struct Paced {
77 label: String,
78 inner: Arc<dyn HttpClient>,
79 min_interval: Duration,
80 next_slot: Mutex<Option<Instant>>,
81 shared: Option<Shared>,
82 in_flight: Option<Arc<tokio::sync::Semaphore>>,
83}
84
85struct Shared {
86 pacing: Arc<dyn Pacing>,
87 clock: fn() -> Timestamp,
89}
90
91impl Paced {
92 pub fn new(
93 label: impl Into<String>,
94 inner: Arc<dyn HttpClient>,
95 min_interval: Duration,
96 ) -> Self {
97 Paced {
98 label: label.into(),
99 inner,
100 min_interval,
101 next_slot: Mutex::new(None),
102 shared: None,
103 in_flight: None,
104 }
105 }
106
107 pub fn max_in_flight(mut self, n: usize) -> Self {
110 self.in_flight = (n > 0).then(|| Arc::new(tokio::sync::Semaphore::new(n)));
111 self
112 }
113
114 pub fn shared(self, pacing: Arc<dyn Pacing>) -> Self {
116 self.shared_by_clock(pacing, Timestamp::now)
117 }
118
119 fn shared_by_clock(mut self, pacing: Arc<dyn Pacing>, clock: fn() -> Timestamp) -> Self {
120 self.shared = Some(Shared { pacing, clock });
121 self
122 }
123
124 async fn claim(&self) -> Instant {
126 let mut next = self.next_slot.lock().await;
127 let now = Instant::now();
128 let mut slot = next.map_or(now, |n| n.max(now));
129 if let Some(shared) = &self.shared {
130 let wall = (shared.clock)();
131 match shared
132 .pacing
133 .claim_slot(&self.label, self.min_interval, wall)
134 {
135 Ok(at) => {
136 let wait = Duration::try_from(at.duration_since(wall)).unwrap_or_default();
137 slot = slot.max(now + wait);
138 }
139 Err(e) => tracing::warn!(
140 target: "aspero::http",
141 provider = %self.label, error = %e, "shared pacing failed; pacing this process alone"
142 ),
143 }
144 }
145 *next = Some(slot + self.min_interval);
146 slot
147 }
148}
149
150#[async_trait]
151impl HttpClient for Paced {
152 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Error> {
153 let queued = Instant::now();
154 let _permit = match &self.in_flight {
155 Some(limit) => Some(limit.acquire().await.expect("never closed")),
156 None => None,
157 };
158 let slot = self.claim().await;
159 tokio::time::sleep_until(slot).await;
160 let started = Instant::now();
161 let url = loggable_url(&request.url);
162 let result = self.inner.send(request).await;
163 let ms = |since: Instant, until: Instant| until.duration_since(since).as_millis() as u64;
164 let (waited_ms, took_ms) = (ms(queued, started), ms(started, Instant::now()));
165 match &result {
166 Ok(resp) => tracing::info!(
167 target: "aspero::http",
168 provider = %self.label, status = resp.status, took_ms, waited_ms, "{url}"
169 ),
170 Err(e) => tracing::warn!(
171 target: "aspero::http",
172 provider = %self.label, error = %e, took_ms, waited_ms, "{url}"
173 ),
174 }
175 result
176 }
177}
178
179fn loggable_url(url: &str) -> String {
182 let path = url
183 .split_once("://")
184 .map_or(url, |(_, rest)| rest.find('/').map_or("/", |i| &rest[i..]));
185 let redacted: String = match path.split_once('?') {
186 None => path.to_owned(),
187 Some((p, query)) => {
188 let params: Vec<String> = query
189 .split('&')
190 .map(|kv| match kv.split_once('=') {
191 Some((k, _)) if matches!(k, "mailto" | "api_key" | "key") => format!("{k}=…"),
192 _ => kv.to_owned(),
193 })
194 .collect();
195 format!("{p}?{}", params.join("&"))
196 }
197 };
198 const MAX: usize = 140;
199 match redacted.char_indices().nth(MAX) {
200 Some((cut, _)) => format!("{}…", &redacted[..cut]),
201 None => redacted,
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 struct Echo;
210
211 #[async_trait]
212 impl HttpClient for Echo {
213 async fn send(&self, _: HttpRequest) -> Result<HttpResponse, Error> {
214 Ok(HttpResponse::new(
215 200,
216 vec![],
217 vec![],
218 Timestamp::UNIX_EPOCH,
219 ))
220 }
221 }
222
223 #[tokio::test(start_paused = true)]
224 async fn paced_requests_start_min_interval_apart() {
225 let paced = Arc::new(Paced::new(
226 "test",
227 Arc::new(Echo),
228 Duration::from_millis(250),
229 ));
230 let start = Instant::now();
231 let mut tasks = Vec::new();
232 for _ in 0..4 {
233 let paced = paced.clone();
234 tasks.push(tokio::spawn(async move {
235 paced.send(HttpRequest::get("http://x")).await.unwrap();
236 start.elapsed().as_millis()
237 }));
238 }
239 let mut starts = Vec::new();
240 for t in tasks {
241 starts.push(t.await.unwrap());
242 }
243 starts.sort_unstable();
244 assert_eq!(starts, [0, 250, 500, 750]);
245 }
246
247 #[test]
248 fn logged_urls_hide_contacts_and_keys() {
249 assert_eq!(
250 loggable_url("https://api.crossref.org/works?query=x&mailto=me%40example.org&rows=3"),
251 "/works?query=x&mailto=…&rows=3"
252 );
253 assert_eq!(
254 loggable_url("https://doi.org/10.1038/nature14539"),
255 "/10.1038/nature14539"
256 );
257 }
258
259 thread_local! {
260 static START: std::cell::Cell<Option<Instant>> = const { std::cell::Cell::new(None) };
261 }
262
263 fn paused_clock() -> Timestamp {
265 let start = START.with(|s| {
266 let start = s.get().unwrap_or_else(Instant::now);
267 s.set(Some(start));
268 start
269 });
270 let elapsed = jiff::SignedDuration::try_from(start.elapsed()).unwrap();
271 Timestamp::UNIX_EPOCH + elapsed
272 }
273
274 #[tokio::test(start_paused = true)]
275 async fn processes_sharing_pacing_keep_to_one_rate() {
276 paused_clock();
277 let store = Arc::new(aspero_store::SqliteCache::open_in_memory().unwrap());
278 let process = || {
279 Arc::new(
280 Paced::new("test", Arc::new(Echo), Duration::from_millis(250))
281 .shared_by_clock(store.clone(), paused_clock),
282 )
283 };
284 let (a, b) = (process(), process());
285 let start = Instant::now();
286 let mut tasks = Vec::new();
287 for paced in [&a, &b, &a, &b] {
288 let paced = paced.clone();
289 tasks.push(tokio::spawn(async move {
290 paced.send(HttpRequest::get("http://x")).await.unwrap();
291 start.elapsed().as_millis()
292 }));
293 }
294 let mut starts = Vec::new();
295 for t in tasks {
296 starts.push(t.await.unwrap());
297 }
298 starts.sort_unstable();
299 assert_eq!(starts, [0, 250, 500, 750]);
300 }
301
302 #[tokio::test(start_paused = true)]
303 async fn idle_time_is_not_banked() {
304 let paced = Paced::new("test", Arc::new(Echo), Duration::from_millis(100));
305 paced.send(HttpRequest::get("http://x")).await.unwrap();
306 tokio::time::sleep(Duration::from_secs(5)).await;
307 let start = Instant::now();
308 paced.send(HttpRequest::get("http://x")).await.unwrap();
309 paced.send(HttpRequest::get("http://x")).await.unwrap();
310 assert_eq!(start.elapsed().as_millis(), 100);
311 }
312}