Skip to main content

ccxt_core/
retry_strategy.rs

1//! Retry strategy module.
2//!
3//! Provides flexible retry strategy configuration and implementation:
4//! - Fixed delay
5//! - Exponential backoff
6//! - Linear backoff
7//! - Configurable retry conditions
8//! - Retry budget mechanism
9
10use crate::error::{ConfigValidationError, Error, ValidationResult};
11use regex::Regex;
12use std::sync::LazyLock;
13use std::time::Duration;
14
15/// Regex pattern for detecting server error messages using word boundary matching.
16/// Matches:
17/// - HTTP status codes 500, 502, 503, 504 as standalone numbers (not part of larger numbers)
18/// - Common server error phrases
19static SERVER_ERROR_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
20    Regex::new(
21        r"(?i)\b(500|502|503|504)\b|internal server error|bad gateway|service unavailable|gateway timeout"
22    ).expect("Invalid server error regex pattern")
23});
24
25/// Retry strategy type.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum RetryStrategyType {
28    /// Fixed delay: wait a constant duration between retries.
29    Fixed,
30    /// Exponential backoff: delay grows exponentially (base_delay * 2^attempt).
31    Exponential,
32    /// Linear backoff: delay grows linearly (base_delay * attempt).
33    Linear,
34}
35
36/// Retry configuration.
37#[derive(Debug, Clone)]
38pub struct RetryConfig {
39    /// Maximum number of retry attempts.
40    pub max_retries: u32,
41    /// Type of retry strategy to use.
42    pub strategy_type: RetryStrategyType,
43    /// Base delay in milliseconds.
44    pub base_delay_ms: u64,
45    /// Maximum delay in milliseconds to prevent excessive backoff.
46    pub max_delay_ms: u64,
47    /// Whether to retry on network errors.
48    pub retry_on_network_error: bool,
49    /// Whether to retry on rate limit errors.
50    pub retry_on_rate_limit: bool,
51    /// Whether to retry on server errors (5xx).
52    pub retry_on_server_error: bool,
53    /// Whether to retry on timeout errors.
54    pub retry_on_timeout: bool,
55    /// Jitter factor (0.0-1.0) to add randomness and prevent thundering herd.
56    pub jitter_factor: f64,
57}
58
59impl Default for RetryConfig {
60    fn default() -> Self {
61        Self {
62            max_retries: 3,
63            strategy_type: RetryStrategyType::Exponential,
64            base_delay_ms: 100,
65            max_delay_ms: 30000,
66            retry_on_network_error: true,
67            retry_on_rate_limit: true,
68            retry_on_server_error: true,
69            retry_on_timeout: true,
70            jitter_factor: 0.1,
71        }
72    }
73}
74
75impl RetryConfig {
76    /// Creates a conservative retry configuration with fewer retries and shorter delays.
77    pub fn conservative() -> Self {
78        Self {
79            max_retries: 2,
80            strategy_type: RetryStrategyType::Fixed,
81            base_delay_ms: 500,
82            max_delay_ms: 5000,
83            retry_on_network_error: true,
84            retry_on_rate_limit: true,
85            retry_on_server_error: false,
86            retry_on_timeout: false,
87            jitter_factor: 0.0,
88        }
89    }
90
91    /// Creates an aggressive retry configuration with more retries and longer delays.
92    pub fn aggressive() -> Self {
93        Self {
94            max_retries: 5,
95            strategy_type: RetryStrategyType::Exponential,
96            base_delay_ms: 200,
97            max_delay_ms: 60000,
98            retry_on_network_error: true,
99            retry_on_rate_limit: true,
100            retry_on_server_error: true,
101            retry_on_timeout: true,
102            jitter_factor: 0.2,
103        }
104    }
105
106    /// Creates a retry configuration for rate limit errors only.
107    pub fn rate_limit_only() -> Self {
108        Self {
109            max_retries: 3,
110            strategy_type: RetryStrategyType::Linear,
111            base_delay_ms: 2000,
112            max_delay_ms: 10000,
113            retry_on_network_error: false,
114            retry_on_rate_limit: true,
115            retry_on_server_error: false,
116            retry_on_timeout: false,
117            jitter_factor: 0.0,
118        }
119    }
120
121    /// Validates the retry configuration parameters.
122    ///
123    /// # Returns
124    ///
125    /// Returns `Ok(ValidationResult)` if the configuration is valid.
126    /// The `ValidationResult` may contain warnings for suboptimal but valid configurations.
127    ///
128    /// Returns `Err(ConfigValidationError)` if the configuration is invalid.
129    ///
130    /// # Validation Rules
131    ///
132    /// - `max_retries` must be <= 10 (excessive retries can cause issues)
133    /// - `base_delay_ms` must be >= 10 (too short delays can cause thundering herd)
134    ///
135    /// # Example
136    ///
137    /// ```rust
138    /// use ccxt_core::retry_strategy::RetryConfig;
139    ///
140    /// let config = RetryConfig::default();
141    /// let result = config.validate();
142    /// assert!(result.is_ok());
143    ///
144    /// let invalid_config = RetryConfig {
145    ///     max_retries: 15, // Too high
146    ///     ..Default::default()
147    /// };
148    /// let result = invalid_config.validate();
149    /// assert!(result.is_err());
150    /// ```
151    pub fn validate(&self) -> Result<ValidationResult, ConfigValidationError> {
152        let warnings = Vec::new();
153
154        // Validate max_retries <= 10
155        if self.max_retries > 10 {
156            return Err(ConfigValidationError::too_high(
157                "max_retries",
158                self.max_retries,
159                10,
160            ));
161        }
162
163        // Validate base_delay_ms >= 10
164        if self.base_delay_ms < 10 {
165            return Err(ConfigValidationError::too_low(
166                "base_delay_ms",
167                self.base_delay_ms,
168                10,
169            ));
170        }
171
172        Ok(ValidationResult::with_warnings(warnings))
173    }
174}
175
176/// Retry strategy.
177#[derive(Debug, Clone)]
178pub struct RetryStrategy {
179    config: RetryConfig,
180}
181
182impl RetryStrategy {
183    /// Creates a new retry strategy with the given configuration.
184    pub fn new(config: RetryConfig) -> Self {
185        Self { config }
186    }
187
188    /// Creates a retry strategy with default configuration.
189    pub fn default_strategy() -> Self {
190        Self::new(RetryConfig::default())
191    }
192
193    /// Determines whether an error should be retried.
194    ///
195    /// # Arguments
196    ///
197    /// * `error` - The error to evaluate.
198    /// * `attempt` - The current retry attempt number (1-based).
199    ///
200    /// # Returns
201    ///
202    /// `true` if the error should be retried, `false` otherwise.
203    pub fn should_retry(&self, error: &Error, attempt: u32) -> bool {
204        if attempt > self.config.max_retries {
205            return false;
206        }
207        match error {
208            Error::Network(_) => self.config.retry_on_network_error,
209            Error::RateLimit { .. } => self.config.retry_on_rate_limit,
210            Error::Exchange(details) => {
211                if self.config.retry_on_server_error && Self::is_server_error(&details.message) {
212                    return true;
213                }
214                if self.config.retry_on_timeout && Self::is_timeout_error(&details.message) {
215                    return true;
216                }
217                false
218            }
219            _ => false,
220        }
221    }
222
223    /// Calculates the retry delay based on strategy type and attempt number.
224    ///
225    /// # Arguments
226    ///
227    /// * `attempt` - The current retry attempt number (1-based).
228    /// * `error` - The error that triggered the retry.
229    ///
230    /// # Returns
231    ///
232    /// The calculated delay duration before the next retry.
233    pub fn calculate_delay(&self, attempt: u32, error: &Error) -> Duration {
234        let base_delay = match self.config.strategy_type {
235            RetryStrategyType::Fixed => self.config.base_delay_ms,
236            RetryStrategyType::Exponential => {
237                self.config.base_delay_ms * 2_u64.pow(attempt.saturating_sub(1))
238            }
239            RetryStrategyType::Linear => self.config.base_delay_ms * u64::from(attempt),
240        };
241
242        let mut delay = base_delay.min(self.config.max_delay_ms);
243
244        if matches!(error, Error::RateLimit { .. }) {
245            delay = delay.max(2000);
246        }
247        if self.config.jitter_factor > 0.0 {
248            delay = self.apply_jitter(delay);
249        }
250
251        Duration::from_millis(delay)
252    }
253
254    /// Applies jitter to the delay to add randomness and prevent thundering herd.
255    fn apply_jitter(&self, delay_ms: u64) -> u64 {
256        use rand::Rng;
257        let mut rng = rand::rngs::ThreadRng::default();
258        #[allow(clippy::cast_precision_loss)]
259        #[allow(clippy::cast_possible_truncation)]
260        let jitter_range = (delay_ms as f64 * self.config.jitter_factor) as u64;
261        let jitter = rng.random_range(0..=jitter_range);
262        delay_ms + jitter
263    }
264
265    /// Checks if the message indicates a server error (5xx).
266    ///
267    /// Uses word boundary matching to avoid false positives like "order_id: 15001234"
268    /// being misidentified as containing a 500 error.
269    ///
270    /// # Arguments
271    ///
272    /// * `msg` - The error message to check.
273    ///
274    /// # Returns
275    ///
276    /// `true` if the message indicates a server error, `false` otherwise.
277    ///
278    /// # Example
279    ///
280    /// ```rust
281    /// use ccxt_core::retry_strategy::RetryStrategy;
282    ///
283    /// // True positives
284    /// assert!(RetryStrategy::is_server_error("500 Internal Server Error"));
285    /// assert!(RetryStrategy::is_server_error("HTTP 502 Bad Gateway"));
286    ///
287    /// // False positives avoided
288    /// assert!(!RetryStrategy::is_server_error("order_id: 15001234"));
289    /// assert!(!RetryStrategy::is_server_error("amount: 5000"));
290    /// ```
291    pub fn is_server_error(msg: &str) -> bool {
292        Self::is_server_error_message(msg)
293    }
294
295    /// Checks if an HTTP status code indicates a server error (5xx).
296    ///
297    /// Server errors are HTTP status codes in the range 500-599.
298    ///
299    /// # Arguments
300    ///
301    /// * `status` - The HTTP status code to check.
302    ///
303    /// # Returns
304    ///
305    /// `true` if the status code is in the 500-599 range, `false` otherwise.
306    ///
307    /// # Example
308    ///
309    /// ```rust
310    /// use ccxt_core::retry_strategy::RetryStrategy;
311    ///
312    /// assert!(RetryStrategy::is_server_error_code(500));
313    /// assert!(RetryStrategy::is_server_error_code(502));
314    /// assert!(RetryStrategy::is_server_error_code(503));
315    /// assert!(RetryStrategy::is_server_error_code(504));
316    /// assert!(RetryStrategy::is_server_error_code(599));
317    ///
318    /// assert!(!RetryStrategy::is_server_error_code(200));
319    /// assert!(!RetryStrategy::is_server_error_code(400));
320    /// assert!(!RetryStrategy::is_server_error_code(404));
321    /// assert!(!RetryStrategy::is_server_error_code(499));
322    /// assert!(!RetryStrategy::is_server_error_code(600));
323    /// ```
324    pub fn is_server_error_code(status: u16) -> bool {
325        (500..600).contains(&status)
326    }
327
328    /// Checks if a message indicates a server error using word boundary matching.
329    ///
330    /// This method uses regex with word boundaries to precisely detect server error
331    /// patterns without false positives from numbers that happen to contain 500, 502, etc.
332    ///
333    /// # Arguments
334    ///
335    /// * `msg` - The error message to check.
336    ///
337    /// # Returns
338    ///
339    /// `true` if the message matches server error patterns, `false` otherwise.
340    ///
341    /// # Patterns Matched
342    ///
343    /// - Status codes: `500`, `502`, `503`, `504` (as standalone words)
344    /// - Phrases: "internal server error", "bad gateway", "service unavailable", "gateway timeout"
345    ///
346    /// # Example
347    ///
348    /// ```rust
349    /// use ccxt_core::retry_strategy::RetryStrategy;
350    ///
351    /// // Matches server error patterns
352    /// assert!(RetryStrategy::is_server_error_message("500 Internal Server Error"));
353    /// assert!(RetryStrategy::is_server_error_message("Error: 502"));
354    /// assert!(RetryStrategy::is_server_error_message("Service Unavailable"));
355    ///
356    /// // Does NOT match numbers containing 500, 502, etc.
357    /// assert!(!RetryStrategy::is_server_error_message("order_id: 15001234"));
358    /// assert!(!RetryStrategy::is_server_error_message("balance: 5020.50"));
359    /// assert!(!RetryStrategy::is_server_error_message("id=25030"));
360    /// ```
361    pub fn is_server_error_message(msg: &str) -> bool {
362        SERVER_ERROR_PATTERN.is_match(msg)
363    }
364
365    /// Checks if the message indicates a timeout error.
366    fn is_timeout_error(msg: &str) -> bool {
367        let msg_lower = msg.to_lowercase();
368        msg_lower.contains("timeout")
369            || msg_lower.contains("timed out")
370            || msg_lower.contains("408")
371    }
372
373    /// Returns a reference to the retry configuration.
374    pub fn config(&self) -> &RetryConfig {
375        &self.config
376    }
377
378    /// Returns the maximum number of retries.
379    pub fn max_retries(&self) -> u32 {
380        self.config.max_retries
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn test_retry_config_default() {
390        let config = RetryConfig::default();
391        assert_eq!(config.max_retries, 3);
392        assert_eq!(config.strategy_type, RetryStrategyType::Exponential);
393        assert_eq!(config.base_delay_ms, 100);
394        assert!(config.retry_on_network_error);
395        assert!(config.retry_on_rate_limit);
396    }
397
398    #[test]
399    fn test_retry_config_conservative() {
400        let config = RetryConfig::conservative();
401        assert_eq!(config.max_retries, 2);
402        assert_eq!(config.strategy_type, RetryStrategyType::Fixed);
403        assert!(!config.retry_on_server_error);
404    }
405
406    #[test]
407    fn test_retry_config_aggressive() {
408        let config = RetryConfig::aggressive();
409        assert_eq!(config.max_retries, 5);
410        assert!(config.retry_on_server_error);
411        assert!(config.retry_on_timeout);
412    }
413
414    #[test]
415    fn test_should_retry_network_error() {
416        let strategy = RetryStrategy::default_strategy();
417        let error = Error::network("Connection failed");
418
419        assert!(strategy.should_retry(&error, 1));
420        assert!(strategy.should_retry(&error, 2));
421        assert!(strategy.should_retry(&error, 3));
422        assert!(!strategy.should_retry(&error, 4));
423    }
424
425    #[test]
426    fn test_should_retry_rate_limit() {
427        let strategy = RetryStrategy::default_strategy();
428        let error = Error::rate_limit("Rate limit exceeded", None);
429
430        assert!(strategy.should_retry(&error, 1));
431        assert!(strategy.should_retry(&error, 3));
432    }
433
434    #[test]
435    fn test_should_not_retry_invalid_request() {
436        let strategy = RetryStrategy::default_strategy();
437        let error = Error::invalid_request("Bad request");
438
439        assert!(!strategy.should_retry(&error, 1));
440    }
441
442    #[test]
443    fn test_calculate_delay_fixed() {
444        let config = RetryConfig {
445            strategy_type: RetryStrategyType::Fixed,
446            base_delay_ms: 1000,
447            jitter_factor: 0.0,
448            ..Default::default()
449        };
450        let strategy = RetryStrategy::new(config);
451        let error = Error::network("test");
452
453        assert_eq!(strategy.calculate_delay(1, &error).as_millis(), 1000);
454        assert_eq!(strategy.calculate_delay(2, &error).as_millis(), 1000);
455        assert_eq!(strategy.calculate_delay(3, &error).as_millis(), 1000);
456    }
457
458    #[test]
459    fn test_calculate_delay_exponential() {
460        let config = RetryConfig {
461            strategy_type: RetryStrategyType::Exponential,
462            base_delay_ms: 100,
463            max_delay_ms: 10000,
464            jitter_factor: 0.0,
465            ..Default::default()
466        };
467        let strategy = RetryStrategy::new(config);
468        let error = Error::network("test");
469
470        assert_eq!(strategy.calculate_delay(1, &error).as_millis(), 100);
471        assert_eq!(strategy.calculate_delay(2, &error).as_millis(), 200);
472        assert_eq!(strategy.calculate_delay(3, &error).as_millis(), 400);
473        assert_eq!(strategy.calculate_delay(4, &error).as_millis(), 800);
474    }
475
476    #[test]
477    fn test_calculate_delay_linear() {
478        let config = RetryConfig {
479            strategy_type: RetryStrategyType::Linear,
480            base_delay_ms: 500,
481            max_delay_ms: 10000,
482            jitter_factor: 0.0,
483            ..Default::default()
484        };
485        let strategy = RetryStrategy::new(config);
486        let error = Error::network("test");
487
488        assert_eq!(strategy.calculate_delay(1, &error).as_millis(), 500);
489        assert_eq!(strategy.calculate_delay(2, &error).as_millis(), 1000);
490        assert_eq!(strategy.calculate_delay(3, &error).as_millis(), 1500);
491    }
492
493    #[test]
494    fn test_calculate_delay_with_max_limit() {
495        let config = RetryConfig {
496            strategy_type: RetryStrategyType::Exponential,
497            base_delay_ms: 1000,
498            max_delay_ms: 5000,
499            jitter_factor: 0.0,
500            ..Default::default()
501        };
502        let strategy = RetryStrategy::new(config);
503        let error = Error::network("test");
504
505        assert_eq!(strategy.calculate_delay(1, &error).as_millis(), 1000);
506        assert_eq!(strategy.calculate_delay(2, &error).as_millis(), 2000);
507        assert_eq!(strategy.calculate_delay(3, &error).as_millis(), 4000);
508        assert_eq!(strategy.calculate_delay(4, &error).as_millis(), 5000);
509        assert_eq!(strategy.calculate_delay(5, &error).as_millis(), 5000);
510    }
511
512    #[test]
513    fn test_is_server_error() {
514        // True positives - should detect server errors
515        assert!(RetryStrategy::is_server_error("500 Internal Server Error"));
516        assert!(RetryStrategy::is_server_error("502 Bad Gateway"));
517        assert!(RetryStrategy::is_server_error("503 Service Unavailable"));
518        assert!(RetryStrategy::is_server_error("504 Gateway Timeout"));
519        assert!(RetryStrategy::is_server_error("HTTP 500"));
520        assert!(RetryStrategy::is_server_error("Error: 502"));
521        assert!(RetryStrategy::is_server_error("Status 503"));
522        assert!(RetryStrategy::is_server_error("internal server error"));
523        assert!(RetryStrategy::is_server_error("bad gateway"));
524        assert!(RetryStrategy::is_server_error("service unavailable"));
525        assert!(RetryStrategy::is_server_error("gateway timeout"));
526
527        // True negatives - should NOT detect as server errors
528        assert!(!RetryStrategy::is_server_error("400 Bad Request"));
529        assert!(!RetryStrategy::is_server_error("404 Not Found"));
530        assert!(!RetryStrategy::is_server_error("200 OK"));
531
532        // False positive prevention - numbers containing 500, 502, etc.
533        assert!(!RetryStrategy::is_server_error("order_id: 15001234"));
534        assert!(!RetryStrategy::is_server_error("balance: 5020.50"));
535        assert!(!RetryStrategy::is_server_error("id=25030"));
536        assert!(!RetryStrategy::is_server_error("amount: 5000"));
537        assert!(!RetryStrategy::is_server_error("price: 50200"));
538        assert!(!RetryStrategy::is_server_error("timestamp: 1500123456789"));
539    }
540
541    #[test]
542    fn test_is_server_error_code() {
543        // Server error codes (500-599)
544        assert!(RetryStrategy::is_server_error_code(500));
545        assert!(RetryStrategy::is_server_error_code(501));
546        assert!(RetryStrategy::is_server_error_code(502));
547        assert!(RetryStrategy::is_server_error_code(503));
548        assert!(RetryStrategy::is_server_error_code(504));
549        assert!(RetryStrategy::is_server_error_code(505));
550        assert!(RetryStrategy::is_server_error_code(599));
551
552        // Non-server error codes
553        assert!(!RetryStrategy::is_server_error_code(200));
554        assert!(!RetryStrategy::is_server_error_code(201));
555        assert!(!RetryStrategy::is_server_error_code(301));
556        assert!(!RetryStrategy::is_server_error_code(400));
557        assert!(!RetryStrategy::is_server_error_code(401));
558        assert!(!RetryStrategy::is_server_error_code(403));
559        assert!(!RetryStrategy::is_server_error_code(404));
560        assert!(!RetryStrategy::is_server_error_code(429));
561        assert!(!RetryStrategy::is_server_error_code(499));
562        assert!(!RetryStrategy::is_server_error_code(600));
563        assert!(!RetryStrategy::is_server_error_code(0));
564    }
565
566    #[test]
567    fn test_is_server_error_message() {
568        // Standard server error messages
569        assert!(RetryStrategy::is_server_error_message(
570            "500 Internal Server Error"
571        ));
572        assert!(RetryStrategy::is_server_error_message("502 Bad Gateway"));
573        assert!(RetryStrategy::is_server_error_message(
574            "503 Service Unavailable"
575        ));
576        assert!(RetryStrategy::is_server_error_message(
577            "504 Gateway Timeout"
578        ));
579
580        // Case insensitive matching
581        assert!(RetryStrategy::is_server_error_message(
582            "INTERNAL SERVER ERROR"
583        ));
584        assert!(RetryStrategy::is_server_error_message("Bad Gateway"));
585        assert!(RetryStrategy::is_server_error_message(
586            "SERVICE UNAVAILABLE"
587        ));
588
589        // Status codes at different positions
590        assert!(RetryStrategy::is_server_error_message("Error 500"));
591        assert!(RetryStrategy::is_server_error_message("HTTP/1.1 502"));
592        assert!(RetryStrategy::is_server_error_message("Status: 503"));
593        assert!(RetryStrategy::is_server_error_message("504"));
594
595        // Word boundary tests - should NOT match
596        assert!(!RetryStrategy::is_server_error_message(
597            "order_id: 15001234"
598        ));
599        assert!(!RetryStrategy::is_server_error_message("balance: 5020.50"));
600        assert!(!RetryStrategy::is_server_error_message("id=25030"));
601        assert!(!RetryStrategy::is_server_error_message("amount: 5000"));
602        assert!(!RetryStrategy::is_server_error_message("price: 50200"));
603        assert!(!RetryStrategy::is_server_error_message(
604            "timestamp: 1500123456789"
605        ));
606        assert!(!RetryStrategy::is_server_error_message("order5020"));
607        assert!(!RetryStrategy::is_server_error_message("5030items"));
608    }
609
610    #[test]
611    fn test_is_timeout_error() {
612        assert!(RetryStrategy::is_timeout_error("Request timeout"));
613        assert!(RetryStrategy::is_timeout_error("Connection timed out"));
614        assert!(RetryStrategy::is_timeout_error("408 Request Timeout"));
615        assert!(!RetryStrategy::is_timeout_error("Connection refused"));
616    }
617
618    #[test]
619    fn test_retry_config_validate_default() {
620        let config = RetryConfig::default();
621        let result = config.validate();
622        assert!(result.is_ok());
623        assert!(result.unwrap().warnings.is_empty());
624    }
625
626    #[test]
627    fn test_retry_config_validate_max_retries_too_high() {
628        let config = RetryConfig {
629            max_retries: 15,
630            ..Default::default()
631        };
632        let result = config.validate();
633        assert!(result.is_err());
634        let err = result.unwrap_err();
635        assert_eq!(err.field_name(), "max_retries");
636        assert!(matches!(
637            err,
638            crate::error::ConfigValidationError::ValueTooHigh { .. }
639        ));
640    }
641
642    #[test]
643    fn test_retry_config_validate_max_retries_boundary() {
644        // max_retries = 10 should be valid
645        let config = RetryConfig {
646            max_retries: 10,
647            ..Default::default()
648        };
649        assert!(config.validate().is_ok());
650
651        // max_retries = 11 should be invalid
652        let config = RetryConfig {
653            max_retries: 11,
654            ..Default::default()
655        };
656        assert!(config.validate().is_err());
657    }
658
659    #[test]
660    fn test_retry_config_validate_base_delay_too_low() {
661        let config = RetryConfig {
662            base_delay_ms: 5,
663            ..Default::default()
664        };
665        let result = config.validate();
666        assert!(result.is_err());
667        let err = result.unwrap_err();
668        assert_eq!(err.field_name(), "base_delay_ms");
669        assert!(matches!(
670            err,
671            crate::error::ConfigValidationError::ValueTooLow { .. }
672        ));
673    }
674
675    #[test]
676    fn test_retry_config_validate_base_delay_boundary() {
677        // base_delay_ms = 10 should be valid
678        let config = RetryConfig {
679            base_delay_ms: 10,
680            ..Default::default()
681        };
682        assert!(config.validate().is_ok());
683
684        // base_delay_ms = 9 should be invalid
685        let config = RetryConfig {
686            base_delay_ms: 9,
687            ..Default::default()
688        };
689        assert!(config.validate().is_err());
690    }
691
692    #[test]
693    fn test_rate_limit_error_minimum_delay() {
694        let config = RetryConfig {
695            strategy_type: RetryStrategyType::Fixed,
696            base_delay_ms: 100, // very short base delay
697            jitter_factor: 0.0,
698            ..Default::default()
699        };
700        let strategy = RetryStrategy::new(config);
701        let error = Error::rate_limit("Rate limit exceeded", None);
702
703        assert!(strategy.calculate_delay(1, &error).as_millis() >= 2000);
704    }
705}