Skip to main content

har/analysis/
retries.rs

1use crate::filter::Filter;
2use crate::grouping::{group_by_fingerprint, group_has_retry, is_retry_trigger};
3use crate::model::Capture;
4use crate::render::human_ms;
5use serde::Serialize;
6
7#[derive(Debug, Serialize)]
8pub struct RetriesResult {
9    pub groups: Vec<RetryGroup>,
10}
11
12#[derive(Debug, Serialize)]
13pub struct RetryGroup {
14    pub fingerprint: String,
15    pub method: String,
16    pub host: String,
17    pub norm_path: String,
18    pub attempts: usize,
19    pub retry_count: usize,
20    pub trigger_statuses: Vec<i64>,
21    pub gaps_ms: Vec<f64>,
22    pub entry_ids: Vec<String>,
23    pub final_status: i64,
24}
25
26/// Report fingerprint groups that exhibit retry behavior (an attempt following
27/// a failed earlier attempt). `top` bounds the list.
28pub fn compute_retries(cap: &Capture, filter: &Filter, top: usize) -> RetriesResult {
29    let entries: Vec<&crate::model::Entry> =
30        cap.entries.iter().filter(|e| filter.matches(e)).collect();
31
32    let mut groups: Vec<RetryGroup> = group_by_fingerprint(&entries)
33        .into_iter()
34        .filter(|(_, g)| group_has_retry(g))
35        .map(|(fp, g)| retry_group(fp, &g))
36        .collect();
37
38    groups.sort_by(|a, b| {
39        b.retry_count
40            .cmp(&a.retry_count)
41            .then(a.fingerprint.cmp(&b.fingerprint))
42    });
43    groups.truncate(top);
44    RetriesResult { groups }
45}
46
47fn retry_group(fingerprint: String, g: &[&crate::model::Entry]) -> RetryGroup {
48    let mut retry_count = 0usize;
49    let mut trigger_statuses: Vec<i64> = Vec::new();
50    let mut seen_failure = false;
51    for e in g {
52        if seen_failure {
53            retry_count += 1;
54        }
55        if is_retry_trigger(e) {
56            seen_failure = true;
57            if !trigger_statuses.contains(&e.status) {
58                trigger_statuses.push(e.status);
59            }
60        }
61    }
62    let gaps_ms: Vec<f64> = g
63        .windows(2)
64        .map(|w| (w[1].started_offset_ms - w[0].started_offset_ms).max(0.0))
65        .collect();
66
67    RetryGroup {
68        fingerprint,
69        method: g[0].method.to_ascii_uppercase(),
70        host: g[0].host.clone(),
71        norm_path: g[0].norm_path.clone(),
72        attempts: g.len(),
73        retry_count,
74        trigger_statuses,
75        gaps_ms,
76        entry_ids: g.iter().map(|e| e.id.clone()).collect(),
77        final_status: g.last().map(|e| e.status).unwrap_or(0),
78    }
79}
80
81/// Render retries as deterministic terminal text.
82pub fn render_retries_text(r: &RetriesResult) -> String {
83    let mut out = String::new();
84    out.push_str("== wiretrail retries ==\n");
85    for g in &r.groups {
86        let triggers: Vec<String> = g.trigger_statuses.iter().map(|s| s.to_string()).collect();
87        out.push_str(&format!(
88            "\n{} {}{}  ({} attempts, {} retries, final {})\n",
89            g.method, g.host, g.norm_path, g.attempts, g.retry_count, g.final_status
90        ));
91        out.push_str(&format!("  triggered by: {}\n", triggers.join(", ")));
92        let gaps: Vec<String> = g.gaps_ms.iter().map(|ms| human_ms(*ms)).collect();
93        out.push_str(&format!("  backoff gaps: {}\n", gaps.join(", ")));
94        out.push_str(&format!("  entries: {}\n", g.entry_ids.join(", ")));
95    }
96    out
97}
98
99#[cfg(test)]
100mod tests {
101    use super::compute_retries;
102    use crate::filter::Filter;
103    use crate::model::{sample_capture, sample_entry};
104
105    fn cap() -> crate::model::Capture {
106        let mut entries = vec![
107            sample_entry(0, "h", "POST", "/bulk", 500),
108            sample_entry(1, "h", "POST", "/bulk", 500),
109            sample_entry(2, "h", "POST", "/bulk", 200),
110            sample_entry(3, "h", "GET", "/clean", 200),
111            sample_entry(4, "h", "GET", "/clean", 200), // pure duplicate, not a retry
112        ];
113        entries[1].started_offset_ms = 100.0;
114        entries[2].started_offset_ms = 300.0;
115        sample_capture(entries)
116    }
117
118    #[test]
119    fn reports_only_retry_groups() {
120        let r = compute_retries(&cap(), &Filter::parse(&[]).unwrap(), 10);
121        assert_eq!(r.groups.len(), 1);
122        let g = &r.groups[0];
123        assert_eq!(g.attempts, 3);
124        assert_eq!(g.retry_count, 2);
125        assert_eq!(g.final_status, 200);
126        assert!(g.trigger_statuses.contains(&500));
127        // gaps between consecutive attempts: 100-0=100, 300-100=200
128        assert_eq!(g.gaps_ms, vec![100.0, 200.0]);
129    }
130}