mockforge_http/
latency_profiles.rs

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