Skip to main content

nmbrs_errorhandler/
router.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Error router: parses config spec, matches errors by type name,
5//! dispatches through the handler chain.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use regex::Regex;
11
12use crate::detail::ErrorDetail;
13use crate::handler::ErrorHandler;
14use crate::handlers::builtin_handler;
15
16/// A compiled mapping from error pattern to handler chain.
17struct HandlerMapping {
18    patterns: Vec<Regex>,
19    handlers: Vec<Arc<dyn ErrorHandler>>,
20}
21
22/// The error router: classifies errors and dispatches to handler chains.
23///
24/// # Config Syntax
25///
26/// ```text
27/// "TimeoutError:retry,warn,counter;.*:stop"
28/// ```
29///
30/// - Error patterns (left of `:`) are regex matched against the error name
31/// - Handler names (right of `:`) are comma-separated, executed in chain order
32/// - Multiple rules are semicolon-separated
33/// - If no pattern prefix, `.*` (match all) is assumed
34///
35/// # Example
36///
37/// ```
38/// use nmbrs_errorhandler::ErrorRouter;
39///
40/// let router = ErrorRouter::parse("TimeoutError:retry,warn;.*:counter,stop").unwrap();
41/// ```
42pub struct ErrorRouter {
43    mappings: Vec<HandlerMapping>,
44    /// Cache: error name → handler chain (lazily populated).
45    cache: std::sync::Mutex<HashMap<String, Vec<Arc<dyn ErrorHandler>>>>,
46    /// The retry BUDGET implied by the spec's `retry` / `retry(N)` verbs, if
47    /// any rule carries one — the largest `N` across rules (a bare `retry`
48    /// contributes [`DEFAULT_RETRY_VERB_BUDGET`]). Consumed by the SRD-82
49    /// Part 3b injection bridge: an op whose policy carries a retry verb and
50    /// that declares no `retry:` of its own gets this budget injected, so
51    /// `errors: "Timeout:retry,warn"` still activates the (conditional)
52    /// retry wrapper while the two config surfaces stay orthogonal.
53    retry_budget: Option<u32>,
54}
55
56/// Additional attempts a bare `retry` verb (no `(N)`) implies. Small on
57/// purpose — an operator who wants a deep budget writes `retry(N)` or sets
58/// the orthogonal `tries:` property directly.
59pub const DEFAULT_RETRY_VERB_BUDGET: u32 = 3;
60
61/// Split a handler token into its verb name and, for `retry` / `retry(N)`,
62/// the retry budget it implies (`retry` → the default; `retry(N)` → `N`
63/// additional attempts). Every other verb passes through with no budget.
64/// A malformed `retry(...)` argument is a parse error, not a silent default.
65fn parse_retry_verb(token: &str) -> Result<(&str, Option<u32>), String> {
66    if token == "retry" {
67        return Ok(("retry", Some(DEFAULT_RETRY_VERB_BUDGET)));
68    }
69    if let Some(arg) = token
70        .strip_prefix("retry(")
71        .and_then(|r| r.strip_suffix(')'))
72    {
73        let n: u32 = arg
74            .trim()
75            .parse()
76            .map_err(|_| format!("invalid retry budget in '{token}': expected retry(N)"))?;
77        return Ok(("retry", Some(n)));
78    }
79    Ok((token, None))
80}
81
82impl ErrorRouter {
83    /// Parse a config spec into a router.
84    pub fn parse(spec: &str) -> Result<Self, String> {
85        let mut mappings = Vec::new();
86        let mut retry_budget: Option<u32> = None;
87
88        for rule in spec.split(';') {
89            let rule = rule.trim();
90            if rule.is_empty() {
91                continue;
92            }
93
94            let (pattern_str, handler_str) = if let Some(colon) = rule.find(':') {
95                (&rule[..colon], &rule[colon + 1..])
96            } else {
97                // No pattern — treat entire string as handler list, match all
98                (".*", rule)
99            };
100
101            let patterns: Vec<Regex> = pattern_str
102                .split(',')
103                .map(|p| p.trim())
104                .filter(|p| !p.is_empty())
105                .map(|p| Regex::new(p).map_err(|e| format!("invalid error pattern '{p}': {e}")))
106                .collect::<Result<Vec<_>, _>>()?;
107
108            let handlers: Vec<Arc<dyn ErrorHandler>> = handler_str
109                .split(',')
110                .map(|h| h.trim())
111                .filter(|h| !h.is_empty())
112                .map(|h| {
113                    // `retry` / `retry(N)` — the verb resolves to the same
114                    // RetryHandler; the budget is recorded on the router for
115                    // the injection bridge (largest across rules wins).
116                    let (name, budget) = parse_retry_verb(h)?;
117                    if let Some(b) = budget {
118                        retry_budget = Some(retry_budget.map_or(b, |cur| cur.max(b)));
119                    }
120                    builtin_handler(name)
121                        .map(|bh| Arc::from(bh) as Arc<dyn ErrorHandler>)
122                        .ok_or_else(|| format!("unknown error handler: '{h}'"))
123                })
124                .collect::<Result<Vec<_>, _>>()?;
125
126            if patterns.is_empty() || handlers.is_empty() {
127                continue;
128            }
129
130            mappings.push(HandlerMapping { patterns, handlers });
131        }
132
133        Ok(Self {
134            mappings,
135            cache: std::sync::Mutex::new(HashMap::new()),
136            retry_budget,
137        })
138    }
139
140    /// The retry budget implied by the spec's `retry` / `retry(N)` verbs;
141    /// `None` when no rule carries a retry verb. See the field doc.
142    pub fn retry_verb_budget(&self) -> Option<u32> {
143        self.retry_budget
144    }
145
146    /// True when some rule carries the literal match-all pattern `.*`
147    /// (written explicitly, or implied by a pattern-less rule). An error
148    /// class matching NO rule falls through to `stop` with only an
149    /// eprintln — a router without a catch-all therefore has a silent
150    /// fall-through mode, which workload-load linting warns about. This
151    /// checks the pattern SOURCE, not regex universality: `.*` is the
152    /// one canonical way to write the catch-all.
153    pub fn has_catch_all(&self) -> bool {
154        self.mappings
155            .iter()
156            .any(|m| m.patterns.iter().any(|p| p.as_str() == ".*"))
157    }
158
159    /// Create a simple router with a default handler for all errors.
160    pub fn default_stop() -> Self {
161        Self::parse(".*:stop").unwrap()
162    }
163
164    /// Create a router that warns and counts all errors.
165    pub fn default_warn_count() -> Self {
166        Self::parse(".*:warn,counter").unwrap()
167    }
168
169    /// Handle an error: classify by name, dispatch through the matching
170    /// handler chain, return the final ErrorDetail.
171    pub fn handle_error(
172        &self,
173        error_name: &str,
174        error_msg: &str,
175        cycle: u64,
176        duration_nanos: u64,
177    ) -> ErrorDetail {
178        let handlers = self.lookup(error_name);
179        let mut detail = ErrorDetail::non_retryable(error_name);
180
181        for handler in &handlers {
182            detail = handler.handle(error_name, error_msg, cycle, duration_nanos, detail);
183        }
184
185        detail
186    }
187
188    fn lookup(&self, error_name: &str) -> Vec<Arc<dyn ErrorHandler>> {
189        // Check cache first
190        {
191            let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
192            if let Some(handlers) = cache.get(error_name) {
193                return handlers.clone();
194            }
195        }
196
197        // Find first matching rule
198        for mapping in &self.mappings {
199            for pattern in &mapping.patterns {
200                if pattern.is_match(error_name) {
201                    let handlers = mapping.handlers.clone();
202                    self.cache
203                        .lock()
204                        .unwrap_or_else(|e| e.into_inner())
205                        .insert(error_name.to_string(), handlers.clone());
206                    return handlers;
207                }
208            }
209        }
210
211        // No match — unhandled error type. Default to stop so
212        // unconfigured errors don't silently pass through.
213        eprintln!(
214            "error: no handler matched error type '{error_name}' — stopping (add a handler pattern to configure)"
215        );
216        let stop_handler = crate::handlers::builtin_handler("stop").unwrap();
217        let handlers = vec![Arc::from(stop_handler) as Arc<dyn ErrorHandler>];
218        self.cache
219            .lock()
220            .unwrap_or_else(|e| e.into_inner())
221            .insert(error_name.to_string(), handlers.clone());
222        handlers
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn parse_simple() {
232        let router = ErrorRouter::parse(".*:warn").unwrap();
233        let detail = router.handle_error("AnyError", "msg", 0, 0);
234        assert!(!detail.is_retryable());
235    }
236
237    #[test]
238    fn parse_multiple_handlers() {
239        let router = ErrorRouter::parse(".*:retry,warn").unwrap();
240        let detail = router.handle_error("AnyError", "msg", 0, 0);
241        assert!(detail.is_retryable());
242    }
243
244    #[test]
245    fn parse_multiple_rules() {
246        let router = ErrorRouter::parse("Timeout.*:retry,warn;.*:ignore").unwrap();
247
248        let d1 = router.handle_error("TimeoutError", "timed out", 0, 0);
249        assert!(d1.is_retryable());
250
251        let d2 = router.handle_error("OtherError", "other", 0, 0);
252        assert!(!d2.is_retryable());
253    }
254
255    #[test]
256    fn first_matching_rule_wins() {
257        let router = ErrorRouter::parse("Timeout:retry;.*:ignore").unwrap();
258        let d = router.handle_error("Timeout", "msg", 0, 0);
259        assert!(d.is_retryable());
260    }
261
262    #[test]
263    fn cache_works() {
264        let router = ErrorRouter::parse(".*:warn").unwrap();
265        // First call populates cache
266        router.handle_error("Err1", "msg", 0, 0);
267        // Second call hits cache
268        router.handle_error("Err1", "msg", 1, 0);
269        let cache = router.cache.lock().unwrap();
270        assert!(cache.contains_key("Err1"));
271    }
272
273    #[test]
274    fn no_pattern_defaults_to_catch_all() {
275        let router = ErrorRouter::parse("warn,counter").unwrap();
276        let detail = router.handle_error("AnyError", "msg", 0, 0);
277        // Should have matched — warn doesn't change retry, counter doesn't either
278        assert!(!detail.is_retryable());
279    }
280
281    #[test]
282    fn stop_handler_in_chain() {
283        let router = ErrorRouter::parse(".*:warn,stop").unwrap();
284        let detail = router.handle_error("Fatal", "kaboom", 42, 0);
285        assert!(
286            detail.should_stop,
287            "stop handler in chain should set should_stop"
288        );
289    }
290
291    #[test]
292    fn unknown_handler_rejected() {
293        let result = ErrorRouter::parse(".*:bogus_handler");
294        assert!(result.is_err());
295    }
296
297    #[test]
298    fn empty_spec_stops_on_unmatched() {
299        let router = ErrorRouter::parse("").unwrap();
300        // No rules — unmatched errors default to stop
301        let detail = router.handle_error("Err", "msg", 0, 0);
302        assert!(detail.should_stop, "unmatched errors should stop execution");
303    }
304
305    #[test]
306    fn default_constructors() {
307        let _ = ErrorRouter::default_stop();
308        let r = ErrorRouter::default_warn_count();
309        let d = r.handle_error("test", "msg", 0, 0);
310        assert!(!d.is_retryable());
311    }
312
313    /// A bare `retry` verb implies the default injection budget; specs
314    /// without a retry verb imply none.
315    #[test]
316    fn bare_retry_verb_implies_default_budget() {
317        let r = ErrorRouter::parse("Timeout:retry,warn;.*:stop").unwrap();
318        assert_eq!(r.retry_verb_budget(), Some(DEFAULT_RETRY_VERB_BUDGET));
319        let r = ErrorRouter::parse(".*:warn,counter").unwrap();
320        assert_eq!(r.retry_verb_budget(), None);
321    }
322
323    /// `retry(N)` carries an explicit budget; the largest across rules wins.
324    #[test]
325    fn parenthesised_retry_budget_wins_max() {
326        let r = ErrorRouter::parse("Timeout:retry(5),warn;Overload:retry(9);.*:stop").unwrap();
327        assert_eq!(r.retry_verb_budget(), Some(9));
328        // Behaviour: retry(N) still routes through the RetryHandler.
329        let d = r.handle_error("Timeout", "t", 0, 0);
330        assert!(d.is_retryable());
331    }
332
333    /// A malformed `retry(...)` argument is a parse error, not a silent
334    /// default.
335    #[test]
336    fn malformed_retry_budget_rejected() {
337        match ErrorRouter::parse(".*:retry(lots)") {
338            Err(err) => assert!(err.contains("retry(N)"), "diagnostic: {err}"),
339            Ok(_) => panic!("retry(lots) must be a parse error"),
340        }
341    }
342}