lastfm_edit/
retry.rs

1use crate::{LastFmError, Result};
2use std::future::Future;
3
4/// Configuration for rate limit detection behavior
5#[derive(Debug, Clone)]
6pub struct RateLimitConfig {
7    /// Whether to detect rate limits by HTTP status codes (429, 403)
8    pub detect_by_status: bool,
9    /// Whether to detect rate limits by response body patterns
10    pub detect_by_patterns: bool,
11    /// Patterns to look for in response bodies (used when detect_by_patterns is true)
12    pub patterns: Vec<String>,
13    /// Additional custom patterns to look for in response bodies
14    pub custom_patterns: Vec<String>,
15}
16
17impl Default for RateLimitConfig {
18    fn default() -> Self {
19        Self {
20            detect_by_status: true,
21            detect_by_patterns: true,
22            patterns: vec![
23                "you've tried to log in too many times".to_string(),
24                "you're requesting too many pages".to_string(),
25                "slow down".to_string(),
26                "too fast".to_string(),
27                "rate limit".to_string(),
28                "throttled".to_string(),
29                "temporarily blocked".to_string(),
30                "temporarily restricted".to_string(),
31                "captcha".to_string(),
32                "verify you're human".to_string(),
33                "prove you're not a robot".to_string(),
34                "security check".to_string(),
35                "service temporarily unavailable".to_string(),
36                "quota exceeded".to_string(),
37                "limit exceeded".to_string(),
38                "daily limit".to_string(),
39            ],
40            custom_patterns: vec![],
41        }
42    }
43}
44
45impl RateLimitConfig {
46    /// Create config with all detection disabled
47    pub fn disabled() -> Self {
48        Self {
49            detect_by_status: false,
50            detect_by_patterns: false,
51            patterns: vec![],
52            custom_patterns: vec![],
53        }
54    }
55
56    /// Create config with only status code detection
57    pub fn status_only() -> Self {
58        Self {
59            detect_by_status: true,
60            detect_by_patterns: false,
61            patterns: vec![],
62            custom_patterns: vec![],
63        }
64    }
65
66    /// Create config with only default pattern detection
67    pub fn patterns_only() -> Self {
68        Self {
69            detect_by_status: false,
70            detect_by_patterns: true,
71            ..Default::default()
72        }
73    }
74
75    /// Create config with custom patterns only (no default patterns)
76    pub fn custom_patterns_only(patterns: Vec<String>) -> Self {
77        Self {
78            detect_by_status: false,
79            detect_by_patterns: false,
80            patterns: vec![],
81            custom_patterns: patterns,
82        }
83    }
84
85    /// Create config with both default and custom patterns
86    pub fn with_custom_patterns(mut self, patterns: Vec<String>) -> Self {
87        self.custom_patterns = patterns;
88        self
89    }
90
91    /// Create config with custom patterns (replaces built-in patterns)
92    pub fn with_patterns(mut self, patterns: Vec<String>) -> Self {
93        self.patterns = patterns;
94        self
95    }
96}
97
98/// Unified configuration for retry behavior and rate limiting
99#[derive(Debug, Clone, Default)]
100pub struct ClientConfig {
101    /// Retry configuration
102    pub retry: RetryConfig,
103    /// Rate limit detection configuration
104    pub rate_limit: RateLimitConfig,
105}
106
107impl ClientConfig {
108    /// Create a new config with default settings
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    /// Create config with retries disabled
114    pub fn with_retries_disabled() -> Self {
115        Self {
116            retry: RetryConfig::disabled(),
117            rate_limit: RateLimitConfig::default(),
118        }
119    }
120
121    /// Create config with rate limit detection disabled
122    pub fn with_rate_limiting_disabled() -> Self {
123        Self {
124            retry: RetryConfig::default(),
125            rate_limit: RateLimitConfig::disabled(),
126        }
127    }
128
129    /// Create config with both retries and rate limiting disabled
130    pub fn minimal() -> Self {
131        Self {
132            retry: RetryConfig::disabled(),
133            rate_limit: RateLimitConfig::disabled(),
134        }
135    }
136
137    /// Set custom retry configuration
138    pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
139        self.retry = retry_config;
140        self
141    }
142
143    /// Set custom rate limit configuration
144    pub fn with_rate_limit_config(mut self, rate_limit_config: RateLimitConfig) -> Self {
145        self.rate_limit = rate_limit_config;
146        self
147    }
148
149    /// Set custom retry count
150    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
151        self.retry.max_retries = max_retries;
152        self.retry.enabled = max_retries > 0;
153        self
154    }
155
156    /// Set custom retry delays
157    pub fn with_retry_delays(mut self, base_delay: u64, max_delay: u64) -> Self {
158        self.retry.base_delay = base_delay;
159        self.retry.max_delay = max_delay;
160        self
161    }
162
163    /// Add custom rate limit patterns
164    pub fn with_custom_rate_limit_patterns(mut self, patterns: Vec<String>) -> Self {
165        self.rate_limit.custom_patterns = patterns;
166        self
167    }
168
169    /// Enable/disable HTTP status code rate limit detection
170    pub fn with_status_detection(mut self, enabled: bool) -> Self {
171        self.rate_limit.detect_by_status = enabled;
172        self
173    }
174
175    /// Enable/disable response pattern rate limit detection
176    pub fn with_pattern_detection(mut self, enabled: bool) -> Self {
177        self.rate_limit.detect_by_patterns = enabled;
178        self
179    }
180}
181
182/// Configuration for retry behavior
183#[derive(Debug, Clone)]
184pub struct RetryConfig {
185    /// Maximum number of retry attempts (set to 0 to disable retries)
186    pub max_retries: u32,
187    /// Base delay for exponential backoff (in seconds)
188    pub base_delay: u64,
189    /// Maximum delay cap (in seconds)
190    pub max_delay: u64,
191    /// Whether retries are enabled at all
192    pub enabled: bool,
193}
194
195impl Default for RetryConfig {
196    fn default() -> Self {
197        Self {
198            max_retries: 3,
199            base_delay: 5,
200            max_delay: 300, // 5 minutes
201            enabled: true,
202        }
203    }
204}
205
206impl RetryConfig {
207    /// Create a config with retries disabled
208    pub fn disabled() -> Self {
209        Self {
210            max_retries: 0,
211            base_delay: 5,
212            max_delay: 300,
213            enabled: false,
214        }
215    }
216
217    /// Create a config with custom retry count
218    pub fn with_retries(max_retries: u32) -> Self {
219        Self {
220            max_retries,
221            enabled: max_retries > 0,
222            ..Default::default()
223        }
224    }
225
226    /// Create a config with custom delays
227    pub fn with_delays(base_delay: u64, max_delay: u64) -> Self {
228        Self {
229            base_delay,
230            max_delay,
231            ..Default::default()
232        }
233    }
234}
235
236/// Result of a retry operation with context
237#[derive(Debug)]
238pub struct RetryResult<T> {
239    /// The successful result
240    pub result: T,
241    /// Number of retry attempts made
242    pub attempts_made: u32,
243    /// Total time spent retrying (in seconds)
244    pub total_retry_time: u64,
245}
246
247/// Execute an async operation with retry logic for rate limiting
248///
249/// This function handles the common pattern of retrying operations that may fail
250/// due to rate limiting, with exponential backoff and configurable limits.
251///
252/// # Arguments
253/// * `config` - Retry configuration
254/// * `operation_name` - Name of the operation for logging
255/// * `operation` - Async function that returns a Result
256/// * `on_rate_limit` - Callback for rate limit events (delay in seconds)
257///
258/// # Returns
259/// A `RetryResult` containing the successful result and retry statistics
260pub async fn retry_with_backoff<T, F, Fut, OnRateLimit>(
261    config: RetryConfig,
262    operation_name: &str,
263    mut operation: F,
264    mut on_rate_limit: OnRateLimit,
265) -> Result<RetryResult<T>>
266where
267    F: FnMut() -> Fut,
268    Fut: Future<Output = Result<T>>,
269    OnRateLimit: FnMut(u64, &str),
270{
271    let mut retries = 0;
272    let mut total_retry_time = 0;
273
274    loop {
275        match operation().await {
276            Ok(result) => {
277                return Ok(RetryResult {
278                    result,
279                    attempts_made: retries,
280                    total_retry_time,
281                });
282            }
283            Err(LastFmError::RateLimit { retry_after }) => {
284                if !config.enabled || retries >= config.max_retries {
285                    if !config.enabled {
286                        log::debug!("Retries disabled for {operation_name} operation");
287                    } else {
288                        log::warn!(
289                            "Max retries ({}) exceeded for {operation_name} operation",
290                            config.max_retries
291                        );
292                    }
293                    return Err(LastFmError::RateLimit { retry_after });
294                }
295
296                // Calculate delay with exponential backoff
297                let base_backoff = config.base_delay * 2_u64.pow(retries);
298                let delay = std::cmp::min(
299                    std::cmp::min(retry_after + base_backoff, config.max_delay),
300                    retry_after + (retries as u64 * 30), // Legacy backoff for compatibility
301                );
302
303                log::info!(
304                    "{} rate limited. Waiting {} seconds before retry {} of {}",
305                    operation_name,
306                    delay,
307                    retries + 1,
308                    config.max_retries
309                );
310
311                // Notify caller about rate limit
312                on_rate_limit(delay, operation_name);
313
314                tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
315                retries += 1;
316                total_retry_time += delay;
317            }
318            Err(other_error) => {
319                return Err(other_error);
320            }
321        }
322    }
323}
324
325/// Simplified retry function for operations that don't need custom rate limit handling
326pub async fn retry_operation<T, F, Fut>(
327    config: RetryConfig,
328    operation_name: &str,
329    operation: F,
330) -> Result<RetryResult<T>>
331where
332    F: FnMut() -> Fut,
333    Fut: Future<Output = Result<T>>,
334{
335    retry_with_backoff(config, operation_name, operation, |delay, op_name| {
336        log::debug!("Rate limited during {op_name}: waiting {delay} seconds");
337    })
338    .await
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use std::sync::atomic::{AtomicU32, Ordering};
345    use std::sync::Arc;
346
347    #[tokio::test]
348    async fn test_successful_operation() {
349        let config = RetryConfig {
350            max_retries: 3,
351            base_delay: 1,
352            max_delay: 60,
353            enabled: true,
354        };
355
356        let result = retry_operation(config, "test", || async { Ok::<i32, LastFmError>(42) }).await;
357
358        assert!(result.is_ok());
359        let retry_result = result.unwrap();
360        assert_eq!(retry_result.result, 42);
361        assert_eq!(retry_result.attempts_made, 0);
362        assert_eq!(retry_result.total_retry_time, 0);
363    }
364
365    #[tokio::test]
366    async fn test_retry_on_rate_limit() {
367        let config = RetryConfig {
368            max_retries: 2,
369            base_delay: 1,
370            max_delay: 60,
371            enabled: true,
372        };
373
374        let call_count = Arc::new(AtomicU32::new(0));
375        let call_count_clone = call_count.clone();
376
377        let result = retry_operation(config, "test", move || {
378            let count = call_count_clone.fetch_add(1, Ordering::SeqCst);
379            async move {
380                if count < 2 {
381                    Err(LastFmError::RateLimit { retry_after: 1 })
382                } else {
383                    Ok::<i32, LastFmError>(42)
384                }
385            }
386        })
387        .await;
388
389        assert!(result.is_ok());
390        let retry_result = result.unwrap();
391        assert_eq!(retry_result.result, 42);
392        assert_eq!(retry_result.attempts_made, 2);
393        assert!(retry_result.total_retry_time >= 2); // At least 2 seconds of delay
394    }
395
396    #[tokio::test]
397    async fn test_max_retries_exceeded() {
398        let config = RetryConfig {
399            max_retries: 1,
400            base_delay: 1,
401            max_delay: 60,
402            enabled: true,
403        };
404
405        let result = retry_operation(config, "test", || async {
406            Err::<i32, LastFmError>(LastFmError::RateLimit { retry_after: 1 })
407        })
408        .await;
409
410        assert!(result.is_err());
411        match result.unwrap_err() {
412            LastFmError::RateLimit { .. } => {} // Expected
413            other => panic!("Expected rate limit error, got: {other:?}"),
414        }
415    }
416
417    #[tokio::test]
418    async fn test_retries_disabled() {
419        let config = RetryConfig::disabled();
420
421        let result = retry_operation(config, "test", || async {
422            Err::<i32, LastFmError>(LastFmError::RateLimit { retry_after: 1 })
423        })
424        .await;
425
426        assert!(result.is_err());
427        match result.unwrap_err() {
428            LastFmError::RateLimit { .. } => {} // Expected - should fail immediately
429            other => panic!("Expected rate limit error, got: {other:?}"),
430        }
431    }
432}