mockforge_http/
latency_profiles.rs

1//! Operation-aware latency/failure profiles (per operationId and per tag).
2use globwalk::GlobWalkerBuilder;
3use rand::{rng, Rng};
4use serde::Deserialize;
5use std::{collections::HashMap, time::Duration};
6use tokio::time::sleep;
7
8/// Latency and failure profile for request simulation
9#[derive(Debug, Clone, Deserialize)]
10pub struct Profile {
11    /// Fixed latency in milliseconds
12    pub fixed_ms: Option<u64>,
13    /// Random jitter to add to fixed latency (milliseconds)
14    pub jitter_ms: Option<u64>,
15    /// Probability of failure (0.0 to 1.0)
16    pub fail_p: Option<f64>,
17    /// HTTP status code to return on failure
18    pub fail_status: Option<u16>,
19}
20
21/// Collection of latency profiles organized by operation ID and tags
22#[derive(Debug, Default, Clone)]
23pub struct LatencyProfiles {
24    /// Profiles keyed by OpenAPI operation ID
25    by_operation: HashMap<String, Profile>,
26    /// Profiles keyed by OpenAPI tag
27    by_tag: HashMap<String, Profile>,
28}
29
30impl LatencyProfiles {
31    /// Load latency profiles from files matching a glob pattern
32    ///
33    /// # Arguments
34    /// * `pattern` - Glob pattern to match profile files (e.g., "profiles/*.yaml")
35    ///
36    /// # Returns
37    /// `Ok(LatencyProfiles)` on success, `Err` if files cannot be read or parsed
38    pub async fn load_from_glob(pattern: &str) -> anyhow::Result<Self> {
39        let mut result = LatencyProfiles::default();
40        for dir_entry in GlobWalkerBuilder::from_patterns(".", &[pattern]).build()? {
41            let path = dir_entry?.path().to_path_buf();
42            if path.extension().map(|e| e == "yaml" || e == "yml").unwrap_or(false) {
43                let text = tokio::fs::read_to_string(&path).await?;
44                let cfg: HashMap<String, Profile> = serde_yaml::from_str(&text)?;
45                for (k, v) in cfg {
46                    if let Some(rest) = k.strip_prefix("operation:") {
47                        result.by_operation.insert(rest.to_string(), v);
48                    } else if let Some(rest) = k.strip_prefix("tag:") {
49                        result.by_tag.insert(rest.to_string(), v);
50                    }
51                }
52            }
53        }
54        Ok(result)
55    }
56
57    /// Check if a fault should be injected for the given operation or tags
58    ///
59    /// Returns the HTTP status code and error message if a fault should be injected,
60    /// otherwise returns None.
61    ///
62    /// # Arguments
63    /// * `operation_id` - OpenAPI operation ID to check for operation-specific profile
64    /// * `tags` - List of tags to check for tag-specific profiles
65    ///
66    /// # Returns
67    /// `Some((status_code, message))` if fault should be injected, `None` otherwise
68    pub async fn maybe_fault(&self, operation_id: &str, tags: &[String]) -> Option<(u16, String)> {
69        let profile = self
70            .by_operation
71            .get(operation_id)
72            .or_else(|| tags.iter().find_map(|t| self.by_tag.get(t)));
73        if let Some(p) = profile {
74            let base = p.fixed_ms.unwrap_or(0);
75            let jitter = p.jitter_ms.unwrap_or(0);
76            let mut rng = rng();
77            let extra: u64 = if jitter > 0 {
78                rng.random_range(0..=jitter)
79            } else {
80                0
81            };
82            sleep(Duration::from_millis(base + extra)).await;
83            if let Some(fp) = p.fail_p {
84                let roll: f64 = rng.random();
85                if roll < fp {
86                    return Some((
87                        p.fail_status.unwrap_or(500),
88                        format!("Injected failure (p={:.2})", fp),
89                    ));
90                }
91            }
92        }
93        None
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn test_profile_creation() {
103        let profile = Profile {
104            fixed_ms: Some(100),
105            jitter_ms: Some(20),
106            fail_p: Some(0.1),
107            fail_status: Some(503),
108        };
109
110        assert_eq!(profile.fixed_ms, Some(100));
111        assert_eq!(profile.jitter_ms, Some(20));
112        assert_eq!(profile.fail_p, Some(0.1));
113        assert_eq!(profile.fail_status, Some(503));
114    }
115
116    #[test]
117    fn test_latency_profiles_default() {
118        let profiles = LatencyProfiles::default();
119        assert!(profiles.by_operation.is_empty());
120        assert!(profiles.by_tag.is_empty());
121    }
122
123    #[tokio::test]
124    async fn test_maybe_fault_no_profile() {
125        let profiles = LatencyProfiles::default();
126        let result = profiles.maybe_fault("test_op", &[]).await;
127        assert!(result.is_none());
128    }
129
130    #[tokio::test]
131    async fn test_maybe_fault_with_operation_profile_no_failure() {
132        let mut profiles = LatencyProfiles::default();
133        profiles.by_operation.insert(
134            "test_op".to_string(),
135            Profile {
136                fixed_ms: Some(1),
137                jitter_ms: Some(1),
138                fail_p: Some(0.0),
139                fail_status: Some(500),
140            },
141        );
142
143        let result = profiles.maybe_fault("test_op", &[]).await;
144        assert!(result.is_none());
145    }
146
147    #[tokio::test]
148    async fn test_maybe_fault_with_tag_profile() {
149        let mut profiles = LatencyProfiles::default();
150        profiles.by_tag.insert(
151            "slow".to_string(),
152            Profile {
153                fixed_ms: Some(1),
154                jitter_ms: None,
155                fail_p: Some(0.0),
156                fail_status: None,
157            },
158        );
159
160        let tags = vec!["slow".to_string()];
161        let result = profiles.maybe_fault("unknown_op", &tags).await;
162        assert!(result.is_none());
163    }
164
165    #[tokio::test]
166    async fn test_maybe_fault_guaranteed_failure() {
167        let mut profiles = LatencyProfiles::default();
168        profiles.by_operation.insert(
169            "failing_op".to_string(),
170            Profile {
171                fixed_ms: Some(0),
172                jitter_ms: None,
173                fail_p: Some(1.0),
174                fail_status: Some(503),
175            },
176        );
177
178        let result = profiles.maybe_fault("failing_op", &[]).await;
179        assert!(result.is_some());
180        let (status, _message) = result.unwrap();
181        assert_eq!(status, 503);
182    }
183
184    #[tokio::test]
185    async fn test_maybe_fault_operation_priority_over_tag() {
186        let mut profiles = LatencyProfiles::default();
187
188        profiles.by_operation.insert(
189            "test_op".to_string(),
190            Profile {
191                fixed_ms: Some(1),
192                jitter_ms: None,
193                fail_p: Some(0.0),
194                fail_status: Some(500),
195            },
196        );
197
198        profiles.by_tag.insert(
199            "test_tag".to_string(),
200            Profile {
201                fixed_ms: Some(100),
202                jitter_ms: None,
203                fail_p: Some(1.0),
204                fail_status: Some(503),
205            },
206        );
207
208        let tags = vec!["test_tag".to_string()];
209        let result = profiles.maybe_fault("test_op", &tags).await;
210
211        // Operation profile should take priority, so no failure
212        assert!(result.is_none());
213    }
214
215    #[test]
216    fn test_profile_deserialization() {
217        let yaml = r#"
218        fixed_ms: 100
219        jitter_ms: 20
220        fail_p: 0.1
221        fail_status: 503
222        "#;
223
224        let profile: Profile = serde_yaml::from_str(yaml).unwrap();
225        assert_eq!(profile.fixed_ms, Some(100));
226        assert_eq!(profile.jitter_ms, Some(20));
227        assert_eq!(profile.fail_p, Some(0.1));
228        assert_eq!(profile.fail_status, Some(503));
229    }
230
231    #[test]
232    fn test_profile_partial_deserialization() {
233        let yaml = r#"
234        fixed_ms: 50
235        "#;
236
237        let profile: Profile = serde_yaml::from_str(yaml).unwrap();
238        assert_eq!(profile.fixed_ms, Some(50));
239        assert!(profile.jitter_ms.is_none());
240        assert!(profile.fail_p.is_none());
241        assert!(profile.fail_status.is_none());
242    }
243}