pubmed_client/
rate_limit.rs

1//! Rate limiting implementation for NCBI API compliance
2//!
3//! This module provides rate limiting functionality that respects NCBI E-utilities guidelines.
4//! Uses a unified implementation that works across both native and WASM targets.
5
6use crate::time::{Duration, Instant, sleep};
7use std::sync::Arc;
8use std::sync::Mutex;
9use tracing::{debug, instrument};
10
11/// NCBI E-utilities rate limits:
12/// - 3 requests per second without API key
13/// - 10 requests per second with API key
14/// - Violations can result in IP blocking
15///
16/// Token bucket rate limiter for NCBI API compliance
17#[derive(Clone)]
18pub struct RateLimiter {
19    bucket: Arc<Mutex<TokenBucket>>,
20}
21
22struct TokenBucket {
23    tokens: f64,
24    capacity: f64,
25    refill_rate: f64, // tokens per second
26    last_refill: Instant,
27}
28
29impl RateLimiter {
30    /// Create a new rate limiter with the specified rate
31    ///
32    /// # Arguments
33    ///
34    /// * `rate` - Maximum requests per second (e.g., 3.0 for NCBI default)
35    ///
36    /// # Examples
37    ///
38    /// ```
39    /// use pubmed_client::RateLimiter;
40    ///
41    /// // Create rate limiter for NCBI API without key (3 req/sec)
42    /// let limiter_default = RateLimiter::new(3.0);
43    ///
44    /// // Create rate limiter for NCBI API with key (10 req/sec)
45    /// let limiter_with_key = RateLimiter::new(10.0);
46    /// ```
47    pub fn new(rate: f64) -> Self {
48        let capacity = rate.max(1.0); // Ensure minimum capacity
49        let now = Instant::now();
50        Self {
51            bucket: Arc::new(Mutex::new(TokenBucket {
52                tokens: capacity,
53                capacity,
54                refill_rate: rate,
55                last_refill: now,
56            })),
57        }
58    }
59
60    /// Create rate limiter for NCBI API without API key (3 requests/second)
61    pub fn ncbi_default() -> Self {
62        Self::new(3.0)
63    }
64
65    /// Create rate limiter for NCBI API with API key (10 requests/second)
66    pub fn ncbi_with_key() -> Self {
67        Self::new(10.0)
68    }
69
70    /// Acquire a token, waiting if necessary to respect rate limits
71    ///
72    /// This method implements a token bucket algorithm with the following behavior:
73    /// 1. Check if tokens are available in the bucket
74    /// 2. If available, consume one token and return immediately
75    /// 3. If not available, wait for the appropriate interval
76    /// 4. Refill the bucket and consume one token
77    ///
78    /// # Examples
79    ///
80    /// ```no_run
81    /// use pubmed_client::RateLimiter;
82    ///
83    /// #[tokio::main]
84    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
85    ///     let limiter = RateLimiter::ncbi_default();
86    ///
87    ///     // This will respect the 3 req/sec limit
88    ///     for i in 0..5 {
89    ///         limiter.acquire().await?;
90    ///         println!("Making API call {}", i + 1);
91    ///         // Make your API call here
92    ///     }
93    ///
94    ///     Ok(())
95    /// }
96    /// ```
97    #[instrument(skip(self))]
98    pub async fn acquire(&self) -> crate::Result<()> {
99        let should_wait = {
100            let mut bucket = self
101                .bucket
102                .lock()
103                .unwrap_or_else(|poisoned| poisoned.into_inner());
104            self.refill_bucket(&mut bucket);
105
106            if bucket.tokens >= 1.0 {
107                bucket.tokens -= 1.0;
108                debug!(remaining_tokens = %bucket.tokens, "Token acquired immediately");
109                false
110            } else {
111                debug!("No tokens available, need to wait");
112                true
113            }
114        };
115
116        if should_wait {
117            // Calculate wait time based on rate
118            let wait_duration = Duration::from_secs(1).as_secs_f64() / self.rate();
119            let wait_duration = Duration::from_millis((wait_duration * 1000.0) as u64);
120
121            debug!(
122                wait_duration_ms = wait_duration.as_millis(),
123                "Waiting for rate limit"
124            );
125            sleep(wait_duration).await;
126
127            // After waiting, refill bucket and consume token
128            let mut bucket = self
129                .bucket
130                .lock()
131                .unwrap_or_else(|poisoned| poisoned.into_inner());
132            self.refill_bucket(&mut bucket);
133            bucket.tokens = bucket.tokens.min(bucket.capacity);
134            if bucket.tokens >= 1.0 {
135                bucket.tokens -= 1.0;
136                debug!(remaining_tokens = %bucket.tokens, "Token acquired after waiting");
137            }
138        }
139
140        Ok(())
141    }
142
143    /// Check if a token is available without blocking
144    ///
145    /// Returns `true` if a token is available and can be acquired immediately.
146    /// This method does not consume a token.
147    pub fn check_available(&self) -> bool {
148        let mut bucket = self
149            .bucket
150            .lock()
151            .unwrap_or_else(|poisoned| poisoned.into_inner());
152        self.refill_bucket(&mut bucket);
153        bucket.tokens >= 1.0
154    }
155
156    /// Get current token count (for testing and monitoring)
157    pub fn token_count(&self) -> f64 {
158        let mut bucket = self
159            .bucket
160            .lock()
161            .unwrap_or_else(|poisoned| poisoned.into_inner());
162        self.refill_bucket(&mut bucket);
163        bucket.tokens
164    }
165
166    /// Get the configured rate limit (requests per second)
167    pub fn rate(&self) -> f64 {
168        let bucket = self
169            .bucket
170            .lock()
171            .unwrap_or_else(|poisoned| poisoned.into_inner());
172        bucket.refill_rate
173    }
174
175    /// Refill the token bucket based on elapsed time
176    fn refill_bucket(&self, bucket: &mut TokenBucket) {
177        let now = Instant::now();
178        let elapsed = now.duration_since(bucket.last_refill);
179
180        // Calculate tokens to add based on elapsed time
181        let tokens_to_add = elapsed.as_secs_f64() * bucket.refill_rate;
182        bucket.tokens = (bucket.tokens + tokens_to_add).min(bucket.capacity);
183
184        bucket.last_refill = now;
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[tokio::test]
193    async fn test_basic_functionality() {
194        let limiter = RateLimiter::new(5.0);
195
196        // Should be able to acquire tokens
197        limiter.acquire().await.unwrap();
198
199        // Check rate
200        let rate = limiter.rate();
201        assert!((rate - 5.0).abs() < 0.1);
202    }
203
204    #[tokio::test]
205    async fn test_check_available() {
206        let limiter = RateLimiter::new(2.0);
207
208        // Should have tokens available initially
209        assert!(limiter.check_available());
210    }
211
212    #[tokio::test]
213    async fn test_ncbi_presets() {
214        let default_limiter = RateLimiter::ncbi_default();
215        let with_key_limiter = RateLimiter::ncbi_with_key();
216
217        assert!((default_limiter.rate() - 3.0).abs() < 0.1);
218        assert!((with_key_limiter.rate() - 10.0).abs() < 0.1);
219    }
220
221    #[tokio::test]
222    async fn test_rate_limiting_basic() {
223        let limiter = RateLimiter::new(1.0); // 1 request per second
224
225        // Should be able to acquire tokens
226        limiter.acquire().await.unwrap();
227        limiter.acquire().await.unwrap(); // This should involve a wait
228
229        // Rate limiter should still work
230        let tokens = limiter.token_count();
231        assert!(tokens >= 0.0);
232    }
233}