Skip to main content

aspero_engine/
transport.rs

1//! The engine's side of the [`HttpClient`] seam: a real client, and pacing.
2
3use 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
14/// `HttpClient` over `reqwest` (rustls). One per provider, so each gets its
15/// own attempt timeout and connection pool.
16pub 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
65/// Spaces request *starts* at least `min_interval` apart, across all tasks
66/// sharing it, and optionally limits requests in flight. Slots are handed
67/// out in arrival order, so waiting is fair.
68///
69/// With [`shared`](Paced::shared) pacing, slots come from a store that
70/// every process using it shares (the cache file), so parallel processes
71/// keep to one rate between them; if the store fails, pacing falls back to
72/// this process alone.
73///
74/// Logs each request (at `info`, target `aspero::http`) with the provider
75/// label, status, duration and time spent waiting for a slot.
76pub 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    /// Wall-clock time, which processes share; swappable for tests.
88    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    /// Keeps at most `n` requests in flight at once (0: no limit), for
108    /// providers that refuse concurrent requests rather than fast ones.
109    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    /// Paces with every other process using `pacing`.
115    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    /// Claims the next slot and returns when it starts.
125    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
179/// A URL for logs: path and query only, contact and key parameters
180/// redacted (logs get pasted into issues), long queries shortened.
181fn 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    /// Wall-clock time that follows tokio's paused clock.
264    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}