Skip to main content

allframe_core/application/
resilience_config.rs

1//! Configuration layer for resilience policies.
2//!
3//! This module provides TOML/YAML-based configuration for resilience policies,
4//! allowing runtime configuration of resilience behavior without code changes.
5//!
6//! # Example TOML Configuration
7//!
8//! ```toml
9//! [resilience]
10//! enabled = true
11//!
12//! [resilience.policies]
13//! default = { retry = { max_attempts = 3 } }
14//! database = { retry = { max_attempts = 5 }, circuit_breaker = { failure_threshold = 10 } }
15//! external_api = { retry = { max_attempts = 2 }, timeout = { duration_seconds = 30 } }
16//!
17//! [resilience.services]
18//! payment_service = "database"
19//! email_service = "external_api"
20//! cache_service = "default"
21//! ```
22//!
23//! # Example YAML Configuration
24//!
25//! ```yaml
26//! resilience:
27//!   enabled: true
28//!   policies:
29//!     default:
30//!       retry:
31//!         max_attempts: 3
32//!     database:
33//!       retry:
34//!         max_attempts: 5
35//!       circuit_breaker:
36//!         failure_threshold: 10
37//!   services:
38//!     payment_service: database
39//!     email_service: external_api
40//! ```
41
42use std::{collections::HashMap, time::Duration};
43
44use serde::{Deserialize, Serialize};
45
46use crate::domain::resilience::{BackoffStrategy, ResiliencePolicy};
47
48/// Top-level resilience configuration
49#[derive(Clone, Debug, Default, Serialize, Deserialize)]
50pub struct ResilienceConfig {
51    /// Whether resilience is enabled globally
52    #[serde(default = "default_enabled")]
53    pub enabled: bool,
54
55    /// Named policy configurations
56    #[serde(default)]
57    pub policies: HashMap<String, PolicyConfig>,
58
59    /// Service-to-policy mappings
60    #[serde(default)]
61    pub services: HashMap<String, String>,
62}
63
64fn default_enabled() -> bool {
65    true
66}
67
68/// Configuration for a single resilience policy
69#[derive(Clone, Debug, Serialize, Deserialize)]
70#[serde(untagged)]
71pub enum PolicyConfig {
72    /// Simple policy (single resilience mechanism)
73    Simple(SimplePolicyConfig),
74
75    /// Combined policy (multiple resilience mechanisms)
76    Combined {
77        /// Retry configuration
78        retry: Option<RetryConfig>,
79
80        /// Circuit breaker configuration
81        circuit_breaker: Option<CircuitBreakerConfig>,
82
83        /// Rate limiting configuration
84        rate_limit: Option<RateLimitConfig>,
85
86        /// Timeout configuration
87        timeout: Option<TimeoutConfig>,
88    },
89}
90
91/// Simple policy configuration for single resilience mechanisms
92#[derive(Clone, Debug, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94pub enum SimplePolicyConfig {
95    /// No resilience
96    None,
97
98    /// Retry configuration
99    Retry(RetryConfig),
100
101    /// Circuit breaker configuration
102    CircuitBreaker(CircuitBreakerConfig),
103
104    /// Rate limiting configuration
105    RateLimit(RateLimitConfig),
106
107    /// Timeout configuration
108    Timeout(TimeoutConfig),
109}
110
111/// Retry policy configuration
112#[derive(Clone, Debug, Serialize, Deserialize)]
113pub struct RetryConfig {
114    /// Maximum number of retry attempts
115    pub max_attempts: u32,
116
117    /// Backoff strategy configuration
118    #[serde(flatten)]
119    pub backoff: BackoffConfig,
120}
121
122/// Backoff strategy configuration
123#[derive(Clone, Debug, Serialize, Deserialize)]
124#[serde(tag = "backoff_type", rename_all = "snake_case")]
125pub enum BackoffConfig {
126    /// Fixed delay between attempts
127    Fixed {
128        /// Delay in milliseconds
129        delay_ms: u64,
130    },
131
132    /// Exponential backoff
133    Exponential {
134        /// Initial delay in milliseconds
135        initial_delay_ms: u64,
136
137        /// Backoff multiplier
138        #[serde(default = "default_multiplier")]
139        multiplier: f64,
140
141        /// Maximum delay in milliseconds (optional)
142        max_delay_ms: Option<u64>,
143
144        /// Whether to add jitter
145        #[serde(default = "default_jitter")]
146        jitter: bool,
147    },
148
149    /// Linear backoff
150    Linear {
151        /// Initial delay in milliseconds
152        initial_delay_ms: u64,
153
154        /// Increment in milliseconds per attempt
155        increment_ms: u64,
156
157        /// Maximum delay in milliseconds (optional)
158        max_delay_ms: Option<u64>,
159    },
160}
161
162fn default_multiplier() -> f64 {
163    2.0
164}
165
166fn default_jitter() -> bool {
167    true
168}
169
170/// Circuit breaker policy configuration
171#[derive(Clone, Debug, Serialize, Deserialize)]
172pub struct CircuitBreakerConfig {
173    /// Number of failures before opening the circuit
174    pub failure_threshold: u32,
175
176    /// Recovery timeout in seconds
177    pub recovery_timeout_seconds: u64,
178
179    /// Number of successes required to close the circuit
180    #[serde(default = "default_success_threshold")]
181    pub success_threshold: u32,
182}
183
184fn default_success_threshold() -> u32 {
185    3
186}
187
188/// Rate limiting policy configuration
189#[derive(Clone, Debug, Serialize, Deserialize)]
190pub struct RateLimitConfig {
191    /// Maximum requests per second
192    pub requests_per_second: u32,
193
194    /// Burst capacity (additional requests allowed)
195    #[serde(default = "default_burst_capacity")]
196    pub burst_capacity: u32,
197}
198
199fn default_burst_capacity() -> u32 {
200    10
201}
202
203/// Timeout policy configuration
204#[derive(Clone, Debug, Serialize, Deserialize)]
205pub struct TimeoutConfig {
206    /// Timeout duration in seconds
207    pub duration_seconds: u64,
208}
209
210impl ResilienceConfig {
211    /// Load configuration from a TOML string
212    pub fn from_toml(content: &str) -> Result<Self, ResilienceConfigError> {
213        toml::from_str(content).map_err(|e| ResilienceConfigError::Toml(e.to_string()))
214    }
215
216    /// Load configuration from a YAML string
217    pub fn from_yaml(_content: &str) -> Result<Self, ResilienceConfigError> {
218        // YAML support is not currently available - serde_yaml is not a dependency
219        Err(ResilienceConfigError::Yaml(
220            "YAML support not available".to_string(),
221        ))
222    }
223
224    /// Load configuration from a file (auto-detects TOML vs YAML based on
225    /// extension)
226    pub fn from_file(path: &std::path::Path) -> Result<Self, ResilienceConfigError> {
227        let content =
228            std::fs::read_to_string(path).map_err(|e| ResilienceConfigError::Io(e.to_string()))?;
229
230        match path.extension().and_then(|s| s.to_str()) {
231            Some("toml") => Self::from_toml(&content),
232            Some("yaml") | Some("yml") => Self::from_yaml(&content),
233            _ => Err(ResilienceConfigError::UnsupportedFormat(
234                path.display().to_string(),
235            )),
236        }
237    }
238
239    /// Get the policy for a specific service
240    pub fn get_policy_for_service(&self, service_name: &str) -> Option<ResiliencePolicy> {
241        if !self.enabled {
242            return Some(ResiliencePolicy::None);
243        }
244
245        // Check if service has a specific policy mapping
246        let policy_name = self.services.get(service_name)?;
247
248        // Get the policy configuration
249        let policy_config = self.policies.get(policy_name)?;
250
251        Some(policy_config.to_policy())
252    }
253
254    /// Get a named policy directly
255    pub fn get_policy(&self, policy_name: &str) -> Option<ResiliencePolicy> {
256        if !self.enabled {
257            return Some(ResiliencePolicy::None);
258        }
259
260        self.policies
261            .get(policy_name)
262            .map(|config| config.to_policy())
263    }
264
265    /// Get the default policy (or None if no policies configured)
266    pub fn get_default_policy(&self) -> ResiliencePolicy {
267        if !self.enabled {
268            return ResiliencePolicy::None;
269        }
270
271        self.policies
272            .get("default")
273            .map(|config| config.to_policy())
274            .unwrap_or(ResiliencePolicy::None)
275    }
276}
277
278impl PolicyConfig {
279    /// Convert configuration to a resilience policy
280    pub fn to_policy(&self) -> ResiliencePolicy {
281        match self {
282            PolicyConfig::Simple(simple) => match simple {
283                SimplePolicyConfig::None => ResiliencePolicy::None,
284                SimplePolicyConfig::Retry(config) => ResiliencePolicy::Retry {
285                    max_attempts: config.max_attempts,
286                    backoff: config.backoff.to_backoff_strategy(),
287                },
288                SimplePolicyConfig::CircuitBreaker(config) => ResiliencePolicy::CircuitBreaker {
289                    failure_threshold: config.failure_threshold,
290                    recovery_timeout: Duration::from_secs(config.recovery_timeout_seconds),
291                    success_threshold: config.success_threshold,
292                },
293                SimplePolicyConfig::RateLimit(config) => ResiliencePolicy::RateLimit {
294                    requests_per_second: config.requests_per_second,
295                    burst_capacity: config.burst_capacity,
296                },
297                SimplePolicyConfig::Timeout(config) => ResiliencePolicy::Timeout {
298                    duration: Duration::from_secs(config.duration_seconds),
299                },
300            },
301
302            PolicyConfig::Combined {
303                retry,
304                circuit_breaker,
305                rate_limit,
306                timeout,
307            } => {
308                let mut policies = Vec::new();
309
310                if let Some(config) = retry {
311                    policies.push(ResiliencePolicy::Retry {
312                        max_attempts: config.max_attempts,
313                        backoff: config.backoff.to_backoff_strategy(),
314                    });
315                }
316
317                if let Some(config) = circuit_breaker {
318                    policies.push(ResiliencePolicy::CircuitBreaker {
319                        failure_threshold: config.failure_threshold,
320                        recovery_timeout: Duration::from_secs(config.recovery_timeout_seconds),
321                        success_threshold: config.success_threshold,
322                    });
323                }
324
325                if let Some(config) = rate_limit {
326                    policies.push(ResiliencePolicy::RateLimit {
327                        requests_per_second: config.requests_per_second,
328                        burst_capacity: config.burst_capacity,
329                    });
330                }
331
332                if let Some(config) = timeout {
333                    policies.push(ResiliencePolicy::Timeout {
334                        duration: Duration::from_secs(config.duration_seconds),
335                    });
336                }
337
338                match policies.len() {
339                    0 => ResiliencePolicy::None,
340                    1 => policies.into_iter().next().unwrap(),
341                    _ => ResiliencePolicy::Combined { policies },
342                }
343            }
344        }
345    }
346}
347
348impl BackoffConfig {
349    /// Convert configuration to a backoff strategy
350    pub fn to_backoff_strategy(&self) -> BackoffStrategy {
351        match self {
352            BackoffConfig::Fixed { delay_ms } => BackoffStrategy::Fixed {
353                delay: Duration::from_millis(*delay_ms),
354            },
355
356            BackoffConfig::Exponential {
357                initial_delay_ms,
358                multiplier,
359                max_delay_ms,
360                jitter,
361            } => BackoffStrategy::Exponential {
362                initial_delay: Duration::from_millis(*initial_delay_ms),
363                multiplier: *multiplier,
364                max_delay: max_delay_ms.map(Duration::from_millis),
365                jitter: *jitter,
366            },
367
368            BackoffConfig::Linear {
369                initial_delay_ms,
370                increment_ms,
371                max_delay_ms,
372            } => BackoffStrategy::Linear {
373                initial_delay: Duration::from_millis(*initial_delay_ms),
374                increment: Duration::from_millis(*increment_ms),
375                max_delay: max_delay_ms.map(Duration::from_millis),
376            },
377        }
378    }
379}
380
381/// Errors that can occur during configuration loading
382#[derive(thiserror::Error, Debug)]
383pub enum ResilienceConfigError {
384    #[error("IO error: {0}")]
385    Io(String),
386
387    #[error("TOML parsing error: {0}")]
388    Toml(String),
389
390    #[error("YAML parsing error: {0}")]
391    Yaml(String),
392
393    #[error("Unsupported file format: {0}")]
394    UnsupportedFormat(String),
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn test_simple_retry_policy_config() {
403        let config = PolicyConfig::Simple(SimplePolicyConfig::Retry(RetryConfig {
404            max_attempts: 3,
405            backoff: BackoffConfig::Exponential {
406                initial_delay_ms: 100,
407                multiplier: 2.0,
408                max_delay_ms: Some(10000),
409                jitter: true,
410            },
411        }));
412
413        let policy = config.to_policy();
414        match policy {
415            ResiliencePolicy::Retry {
416                max_attempts,
417                backoff: BackoffStrategy::Exponential { .. },
418            } => {
419                assert_eq!(max_attempts, 3);
420            }
421            _ => panic!("Expected retry policy"),
422        }
423    }
424
425    #[test]
426    fn test_combined_policy_config() {
427        let config = PolicyConfig::Combined {
428            retry: Some(RetryConfig {
429                max_attempts: 3,
430                backoff: BackoffConfig::Fixed { delay_ms: 1000 },
431            }),
432            circuit_breaker: Some(CircuitBreakerConfig {
433                failure_threshold: 5,
434                recovery_timeout_seconds: 30,
435                success_threshold: 2,
436            }),
437            rate_limit: None,
438            timeout: Some(TimeoutConfig {
439                duration_seconds: 60,
440            }),
441        };
442
443        let policy = config.to_policy();
444        match policy {
445            ResiliencePolicy::Combined { policies } => {
446                assert_eq!(policies.len(), 3);
447            }
448            _ => panic!("Expected combined policy"),
449        }
450    }
451
452    #[test]
453    fn test_resilience_config_from_toml() {
454        let toml_content = r#"
455            enabled = true
456
457            [policies.default]
458            retry = { max_attempts = 3, backoff_type = "exponential", initial_delay_ms = 100 }
459
460            [policies.database]
461            retry = { max_attempts = 5, backoff_type = "fixed", delay_ms = 1000 }
462            circuit_breaker = { failure_threshold = 10, recovery_timeout_seconds = 30 }
463
464            [services]
465            payment_service = "database"
466            cache_service = "default"
467        "#;
468
469        let config = ResilienceConfig::from_toml(toml_content).unwrap();
470        assert!(config.enabled);
471        assert_eq!(config.policies.len(), 2);
472        assert_eq!(config.services.len(), 2);
473
474        // Test service policy resolution
475        let payment_policy = config.get_policy_for_service("payment_service").unwrap();
476        match payment_policy {
477            ResiliencePolicy::Combined { policies } => {
478                assert_eq!(policies.len(), 2); // retry + circuit breaker
479            }
480            _ => panic!("Expected combined policy for payment service"),
481        }
482    }
483
484    #[test]
485    fn test_backoff_config_conversion() {
486        let fixed_config = BackoffConfig::Fixed { delay_ms: 500 };
487        let fixed_strategy = fixed_config.to_backoff_strategy();
488        match fixed_strategy {
489            BackoffStrategy::Fixed { delay } => {
490                assert_eq!(delay, Duration::from_millis(500));
491            }
492            _ => panic!("Expected fixed backoff"),
493        }
494
495        let exp_config = BackoffConfig::Exponential {
496            initial_delay_ms: 100,
497            multiplier: 2.0,
498            max_delay_ms: Some(5000),
499            jitter: true,
500        };
501        let exp_strategy = exp_config.to_backoff_strategy();
502        match exp_strategy {
503            BackoffStrategy::Exponential {
504                initial_delay,
505                multiplier,
506                max_delay,
507                jitter,
508            } => {
509                assert_eq!(initial_delay, Duration::from_millis(100));
510                assert_eq!(multiplier, 2.0);
511                assert_eq!(max_delay, Some(Duration::from_millis(5000)));
512                assert!(jitter);
513            }
514            _ => panic!("Expected exponential backoff"),
515        }
516    }
517
518    #[test]
519    fn test_config_disabled_behavior() {
520        let config = ResilienceConfig::from_toml("enabled = false").unwrap();
521        assert!(!config.enabled);
522
523        // All policies should return None when disabled
524        assert_eq!(config.get_default_policy(), ResiliencePolicy::None);
525        assert_eq!(config.get_policy("any"), Some(ResiliencePolicy::None));
526        assert_eq!(
527            config.get_policy_for_service("any"),
528            Some(ResiliencePolicy::None)
529        );
530    }
531}