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#[derive(Debug, Clone, Deserialize)]
9pub struct Profile {
10    pub fixed_ms: Option<u64>,
11    pub jitter_ms: Option<u64>,
12    pub fail_p: Option<f64>,
13    pub fail_status: Option<u16>,
14}
15
16#[derive(Debug, Default, Clone)]
17pub struct LatencyProfiles {
18    by_operation: HashMap<String, Profile>,
19    by_tag: HashMap<String, Profile>,
20}
21
22impl LatencyProfiles {
23    pub async fn load_from_glob(pattern: &str) -> anyhow::Result<Self> {
24        let mut result = LatencyProfiles::default();
25        for dir_entry in GlobWalkerBuilder::from_patterns(".", &[pattern]).build()? {
26            let path = dir_entry?.path().to_path_buf();
27            if path.extension().map(|e| e == "yaml" || e == "yml").unwrap_or(false) {
28                let text = tokio::fs::read_to_string(&path).await?;
29                let cfg: HashMap<String, Profile> = serde_yaml::from_str(&text)?;
30                for (k, v) in cfg {
31                    if let Some(rest) = k.strip_prefix("operation:") {
32                        result.by_operation.insert(rest.to_string(), v);
33                    } else if let Some(rest) = k.strip_prefix("tag:") {
34                        result.by_tag.insert(rest.to_string(), v);
35                    }
36                }
37            }
38        }
39        Ok(result)
40    }
41
42    pub async fn maybe_fault(&self, operation_id: &str, tags: &[String]) -> Option<(u16, String)> {
43        let profile = self
44            .by_operation
45            .get(operation_id)
46            .or_else(|| tags.iter().find_map(|t| self.by_tag.get(t)));
47        if let Some(p) = profile {
48            let base = p.fixed_ms.unwrap_or(0);
49            let jitter = p.jitter_ms.unwrap_or(0);
50            let mut rng = rng();
51            let extra: u64 = if jitter > 0 {
52                rng.random_range(0..=jitter)
53            } else {
54                0
55            };
56            sleep(Duration::from_millis(base + extra)).await;
57            if let Some(fp) = p.fail_p {
58                let roll: f64 = rng.random();
59                if roll < fp {
60                    return Some((
61                        p.fail_status.unwrap_or(500),
62                        format!("Injected failure (p={:.2})", fp),
63                    ));
64                }
65            }
66        }
67        None
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn test_profile_creation() {
77        let profile = Profile {
78            fixed_ms: Some(100),
79            jitter_ms: Some(20),
80            fail_p: Some(0.1),
81            fail_status: Some(503),
82        };
83
84        assert_eq!(profile.fixed_ms, Some(100));
85        assert_eq!(profile.jitter_ms, Some(20));
86        assert_eq!(profile.fail_p, Some(0.1));
87        assert_eq!(profile.fail_status, Some(503));
88    }
89
90    #[test]
91    fn test_latency_profiles_default() {
92        let profiles = LatencyProfiles::default();
93        assert!(profiles.by_operation.is_empty());
94        assert!(profiles.by_tag.is_empty());
95    }
96
97    #[tokio::test]
98    async fn test_maybe_fault_no_profile() {
99        let profiles = LatencyProfiles::default();
100        let result = profiles.maybe_fault("test_op", &[]).await;
101        assert!(result.is_none());
102    }
103
104    #[tokio::test]
105    async fn test_maybe_fault_with_operation_profile_no_failure() {
106        let mut profiles = LatencyProfiles::default();
107        profiles.by_operation.insert(
108            "test_op".to_string(),
109            Profile {
110                fixed_ms: Some(1),
111                jitter_ms: Some(1),
112                fail_p: Some(0.0),
113                fail_status: Some(500),
114            },
115        );
116
117        let result = profiles.maybe_fault("test_op", &[]).await;
118        assert!(result.is_none());
119    }
120
121    #[tokio::test]
122    async fn test_maybe_fault_with_tag_profile() {
123        let mut profiles = LatencyProfiles::default();
124        profiles.by_tag.insert(
125            "slow".to_string(),
126            Profile {
127                fixed_ms: Some(1),
128                jitter_ms: None,
129                fail_p: Some(0.0),
130                fail_status: None,
131            },
132        );
133
134        let tags = vec!["slow".to_string()];
135        let result = profiles.maybe_fault("unknown_op", &tags).await;
136        assert!(result.is_none());
137    }
138
139    #[tokio::test]
140    async fn test_maybe_fault_guaranteed_failure() {
141        let mut profiles = LatencyProfiles::default();
142        profiles.by_operation.insert(
143            "failing_op".to_string(),
144            Profile {
145                fixed_ms: Some(0),
146                jitter_ms: None,
147                fail_p: Some(1.0),
148                fail_status: Some(503),
149            },
150        );
151
152        let result = profiles.maybe_fault("failing_op", &[]).await;
153        assert!(result.is_some());
154        let (status, _message) = result.unwrap();
155        assert_eq!(status, 503);
156    }
157
158    #[tokio::test]
159    async fn test_maybe_fault_operation_priority_over_tag() {
160        let mut profiles = LatencyProfiles::default();
161
162        profiles.by_operation.insert(
163            "test_op".to_string(),
164            Profile {
165                fixed_ms: Some(1),
166                jitter_ms: None,
167                fail_p: Some(0.0),
168                fail_status: Some(500),
169            },
170        );
171
172        profiles.by_tag.insert(
173            "test_tag".to_string(),
174            Profile {
175                fixed_ms: Some(100),
176                jitter_ms: None,
177                fail_p: Some(1.0),
178                fail_status: Some(503),
179            },
180        );
181
182        let tags = vec!["test_tag".to_string()];
183        let result = profiles.maybe_fault("test_op", &tags).await;
184
185        // Operation profile should take priority, so no failure
186        assert!(result.is_none());
187    }
188
189    #[test]
190    fn test_profile_deserialization() {
191        let yaml = r#"
192        fixed_ms: 100
193        jitter_ms: 20
194        fail_p: 0.1
195        fail_status: 503
196        "#;
197
198        let profile: Profile = serde_yaml::from_str(yaml).unwrap();
199        assert_eq!(profile.fixed_ms, Some(100));
200        assert_eq!(profile.jitter_ms, Some(20));
201        assert_eq!(profile.fail_p, Some(0.1));
202        assert_eq!(profile.fail_status, Some(503));
203    }
204
205    #[test]
206    fn test_profile_partial_deserialization() {
207        let yaml = r#"
208        fixed_ms: 50
209        "#;
210
211        let profile: Profile = serde_yaml::from_str(yaml).unwrap();
212        assert_eq!(profile.fixed_ms, Some(50));
213        assert!(profile.jitter_ms.is_none());
214        assert!(profile.fail_p.is_none());
215        assert!(profile.fail_status.is_none());
216    }
217}